diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 44cb5c01..6baef872 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1016,7 +1016,7 @@ jobs: # in somebody else's file. run: | set -euo pipefail - watched='internal/format/registry.go internal/damage/damage.go cmd/tfg/main.go internal/gui/window/run.go internal/gui/run_cgo.go internal/gui/window/tidy.go internal/audit/parallel.go internal/engine/parallel.go go.mod .github/workflows/ci.yml .github/build-tags' + watched='internal/format/registry.go internal/damage/damage.go cmd/tfg/main.go internal/gui/window/run.go internal/gui/run_cgo.go internal/gui/window/tidy.go internal/audit/parallel.go internal/engine/parallel.go internal/tool/tool.go internal/gui/window/tools.go go.mod .github/workflows/ci.yml .github/build-tags' # 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 fa71bf9d..435c72a9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,14 @@ because it turns other people's test suites red. ### Added +- **A Tools tab, and `tfg tool`.** Small things to do with files you already + have, in the window and on the command line with the same settings. The + first works out the checksum of a file - md5, sha1, sha256, sha512 or crc32, + sha256 unless you ask - and compares it with one you were given, telling the + algorithm from its length: `tfg tool checksum file.iso --expected + `. A checksum that does not match ends with code 7, the same as + `tfg verify`. The file is only read. `tfg tool list` and `tfg tool show + ` say what there is, both with `--json`. - **The window in Polish, and a Preferences tab.** The window now speaks the language your system is set to when it has that language, and English otherwise - so on a system set to Polish it opens in Polish. Preferences @@ -96,6 +104,11 @@ because it turns other people's test suites red. the file to remove, instead of calling an empty file the record of an earlier run. +- **Ctrl+Enter, Ctrl+P and Escape act on the tab you are looking at.** With + About on show, Ctrl+Enter used to run Generate on the last work tab you had + left, which you could not see, and Escape could stop a run there. On a tab + with nothing to run the three keys now do nothing. + ## [0.4.0] - 2026-09-25 ### Changed diff --git a/README.md b/README.md index 07de9174..367d4ffe 100644 --- a/README.md +++ b/README.md @@ -285,6 +285,7 @@ tfg recipe fmt print a recipe in its settled shape tfg preset build a set of files from a named test question tfg formats list the formats this build supports tfg damage list the ways this build can break a file on purpose +tfg tool small things to do with files you already have tfg version print the tool version tfg license print the licence and what it means for generated files ``` @@ -380,6 +381,21 @@ tfg formats [--json] every format, with fidelity, determinism and smallest s tfg formats what a single format accepts ``` +### `tfg tool` + +``` +tfg tool list [--json] the tools this build has +tfg tool show [--json] what one tool works on and takes +tfg tool checksum [--algorithm sha256] [--expected ] [--json] +``` + +Small things to do with files you already have, beside the generator. Every tool +is also on the Tools tab of the window, with the same settings. The first one +works out the checksum of a file - md5, sha1, sha256, sha512 or crc32, sha256 +unless you ask - and compares it with one you were given, telling the algorithm +from its length. A checksum that does not match ends with code 7, the same as +`tfg verify`. crc32 is the one ZIP and PNG use, not the one `cksum` prints. + ### `tfg damage` ``` @@ -666,8 +682,8 @@ not a cut down version: a test compares the two interfaces capability by capability, and anything only one of them can do has to be declared and justified rather than quietly drifting apart. -Five screens - one batch, presets, several batches at once, preferences, and -about. It shows what a run would cost before writing anything, reports progress +Six screens - one batch, presets, several batches at once, tools, preferences, +and about. It shows what a run would cost before writing anything, reports progress while it runs, and can be cancelled part way without leaving a half written file behind. The window speaks your system's language when it has it (English and Polish today) and English otherwise. Preferences lets you choose another, and diff --git a/internal/cli/cli.go b/internal/cli/cli.go index d4d4d2e7..4ac339b8 100644 --- a/internal/cli/cli.go +++ b/internal/cli/cli.go @@ -20,6 +20,7 @@ import ( "github.com/donislawdev/TestingFilesGenerator/internal/engine" "github.com/donislawdev/TestingFilesGenerator/internal/format" _ "github.com/donislawdev/TestingFilesGenerator/internal/format/all" + _ "github.com/donislawdev/TestingFilesGenerator/internal/tool/all" ) // Exit codes are a frozen contract. Changing what one means is a breaking diff --git a/internal/cli/commands.go b/internal/cli/commands.go index a24c2575..cfa0cca8 100644 --- a/internal/cli/commands.go +++ b/internal/cli/commands.go @@ -99,6 +99,7 @@ func commands() []command { return damageCmd(args, out, errOut) }, }, + {Verb: "tool", Summary: "small things to do with files you already have", Run: toolCmd}, { Verb: "version", Aliases: []string{"--version"}, Summary: "print the tool version", diff --git a/internal/cli/errors.go b/internal/cli/errors.go index 1dcc2c9f..5b98b24a 100644 --- a/internal/cli/errors.go +++ b/internal/cli/errors.go @@ -19,6 +19,7 @@ import ( "github.com/donislawdev/TestingFilesGenerator/internal/manifest" "github.com/donislawdev/TestingFilesGenerator/internal/preset" "github.com/donislawdev/TestingFilesGenerator/internal/recipe" + "github.com/donislawdev/TestingFilesGenerator/internal/tool" ) // describeError renders an error for a person, in English, whatever language @@ -145,6 +146,9 @@ func mustBeFile(path, kind, command string) error { // subject changes is the cut that costs nothing to read: a caller still asks // one question and gets one number. func classify(err error) int { + if code, ok := classifyTool(err); ok { + return code + } if code, ok := classifyRequest(err); ok { return code } @@ -170,6 +174,23 @@ func classify(err error) int { return ExitRuntime } +// classifyTool covers the refusals of a tool, which say what kind they are +// (tool.Classified) - so a tool added tomorrow ends with the right code and +// nothing here names it. +func classifyTool(err error) (int, bool) { + var refusal tool.Classified + if !errors.As(err, &refusal) { + return 0, false + } + switch refusal.Class() { + case tool.Asked: + return ExitUsage, true + case tool.Reading: + return ExitIO, true + } + return 0, false +} + // classifyRequest covers what was asked for: the recipe, the preset and what // the format can deliver. func classifyRequest(err error) (int, bool) { diff --git a/internal/cli/toolcmd.go b/internal/cli/toolcmd.go new file mode 100644 index 00000000..ed18d814 --- /dev/null +++ b/internal/cli/toolcmd.go @@ -0,0 +1,358 @@ +// Part of package cli. See cli.go. +package cli + +import ( + "context" + "flag" + "fmt" + "io" + "strings" + + "github.com/donislawdev/TestingFilesGenerator/internal/format" + "github.com/donislawdev/TestingFilesGenerator/internal/tool" +) + +// toolEntry is what "tfg tool list --json" and "tfg tool show --json" return. +// +// The settings ride in the same propertyEntry a format, a preset and a damage +// use, because a tool setting IS a format.Property - a script that draws a +// field from one draws it from the others with no new code. +type toolEntry struct { + ID string `json:"id"` + Question string `json:"question"` + Detail string `json:"detail"` + Inputs []toolInputEntry `json:"inputs,omitempty"` + Settings []propertyEntry `json:"settings,omitempty"` +} + +// toolInputEntry is one thing a tool works on. +type toolInputEntry struct { + Name string `json:"name"` + Kind string `json:"kind"` + Detail string `json:"detail"` +} + +// propertyEntriesFor is a list of declared settings as a script sees them. +// +// The fourth place that needs this, and the first written once for anybody - +// formats, presets and damages each build it inline (O261). +func propertyEntriesFor(declared []format.Property) []propertyEntry { + out := make([]propertyEntry, 0, len(declared)) + for _, p := range declared { + out = append(out, propertyEntry{ + Name: p.Name, Kind: string(p.Kind), Min: p.Min, Max: p.Max, + Unit: p.Unit, Choices: p.Choices, Default: p.Default, Detail: p.Detail, + Group: p.Group, + }) + } + return out +} + +func toolEntryFor(d tool.Descriptor) toolEntry { + inputs := make([]toolInputEntry, 0, len(d.Inputs)) + for _, in := range d.Inputs { + inputs = append(inputs, toolInputEntry{Name: in.Name, Kind: string(in.Kind), Detail: in.Detail}) + } + return toolEntry{ + ID: d.ID, Question: d.Question, Detail: d.Detail, + Inputs: inputs, Settings: propertyEntriesFor(d.Settings), + } +} + +// toolCmd answers "tfg tool": what the tools are, what one takes, or the run +// of one. +// +// list and show are words of their own rather than "tfg tool" alone and "tfg +// tool ", which is how tfg damage answers - because here the id RUNS the +// tool, the way a verb does. tool.Register refuses a tool called list or show +// for the same reason. +func toolCmd(ctx context.Context, args []string, out, errOut io.Writer) int { + if len(args) > 0 { + switch args[0] { + case "list": + return toolList(args[1:], out, errOut) + case "show": + return toolShow(args[1:], out, errOut) + } + if !strings.HasPrefix(args[0], "-") { + return toolRun(ctx, args[0], args[1:], out, errOut) + } + } + if helpRequested(args) { + toolUsage(out) + return ExitOK + } + fmt.Fprintln(errOut, "tfg: tool takes one operation: list, show or the id of a tool. Example: tfg tool list") + toolUsage(errOut) + return ExitUsage +} + +func toolUsage(w io.Writer) { + fmt.Fprint(w, `tfg tool - small things to do with files you already have. + +Usage: + tfg tool list the tools this build has + tfg tool show what one tool works on and takes + tfg tool [flags] run it + +Every tool is also on the Tools tab of the window, with the same settings. +`) +} + +func toolList(args []string, out, errOut io.Writer) int { + fs := flag.NewFlagSet("tool list", flag.ContinueOnError) + fs.SetOutput(errOut) + asJSON := fs.Bool("json", false, "write the list as JSON to standard output") + fs.Usage = func() { toolUsage(errOut) } + if helpRequested(args) { + toolUsage(out) + return ExitOK + } + if err := fs.Parse(args); err != nil { + return ExitUsage + } + if fs.NArg() > 0 { + fmt.Fprintf(errOut, "tfg: tool list takes no names and %q came after it. Run \"tfg tool show %s\" for one tool.\n", + fs.Arg(0), fs.Arg(0)) + return ExitUsage + } + all := tool.All() + if *asJSON { + entries := make([]toolEntry, 0, len(all)) + for _, d := range all { + entries = append(entries, toolEntryFor(d)) + } + return renderJSON(entries, out, errOut) + } + fmt.Fprintf(out, "%-12s %s\n", "TOOL", "QUESTION") + for _, d := range all { + fmt.Fprintf(out, "%-12s %s\n", d.ID, d.Question) + fmt.Fprintf(out, " %s\n", d.Detail) + } + fmt.Fprint(out, "\nRun \"tfg tool show \" for what one tool takes.\n") + return ExitOK +} + +func toolShow(args []string, out, errOut io.Writer) int { + fs := flag.NewFlagSet("tool show", flag.ContinueOnError) + fs.SetOutput(errOut) + asJSON := fs.Bool("json", false, "write the description as JSON to standard output") + fs.Usage = func() { toolUsage(errOut) } + if helpRequested(args) { + toolUsage(out) + return ExitOK + } + leading, rest := splitLeadingPath(args) + if err := fs.Parse(rest); err != nil { + return ExitUsage + } + wanted, ok := atMostOneName(leading, fs, errOut) + if !ok { + return ExitUsage + } + if wanted == "" { + fmt.Fprintln(errOut, "tfg: tool show takes the id of one tool. Run \"tfg tool list\" to see them.") + return ExitUsage + } + d, err := tool.Get(wanted) + if err != nil { + fmt.Fprintf(errOut, "tfg: %s\n", describeError(err)) + return classify(err) + } + if *asJSON { + return renderJSON(toolEntryFor(d), out, errOut) + } + describeOneTool(d, out) + return ExitOK +} + +// describeOneTool prints everything one tool declares, in the order somebody +// about to run it needs: what it answers, what to type, what each part means. +func describeOneTool(d tool.Descriptor, out io.Writer) { + fmt.Fprintf(out, "%s - %s\n", d.ID, d.Question) + fmt.Fprintf(out, " %s\n\n", d.Detail) + fmt.Fprintf(out, "Usage:\n %s\n", toolExample(d)) + for _, in := range d.Inputs { + fmt.Fprintf(out, " %-12s %s\n", "<"+in.Name+">", in.Detail) + } + if len(d.Settings) == 0 { + return + } + fmt.Fprint(out, "\nSettings:\n") + for _, p := range d.Settings { + fmt.Fprintf(out, " %-12s %s\n", "--"+p.Name, p.Allowed()) + if p.Detail != "" { + fmt.Fprintf(out, " %-12s %s\n", "", p.Detail) + } + } +} + +// toolExample is the command that runs a tool, built from what it declares +// rather than written out, so it cannot name an input that is gone. +func toolExample(d tool.Descriptor) string { + parts := []string{"tfg tool", d.ID} + for _, in := range d.Inputs { + parts = append(parts, "<"+in.Name+">") + } + if len(d.Settings) > 0 { + parts = append(parts, "[settings]") + } + return strings.Join(parts, " ") + " [--json]" +} + +// toolRun runs one tool with what the command line gave it. +func toolRun(ctx context.Context, id string, args []string, out, errOut io.Writer) int { + d, err := tool.Get(id) + if err != nil { + fmt.Fprintf(errOut, "tfg: %s\n", describeError(err)) + return classify(err) + } + fs := flag.NewFlagSet("tool "+id, flag.ContinueOnError) + fs.SetOutput(errOut) + asJSON := fs.Bool("json", false, "write the result as JSON") + fs.Usage = func() { describeOneTool(d, errOut) } + if helpRequested(args) { + describeOneTool(d, out) + return ExitOK + } + for _, p := range d.Settings { + if fs.Lookup(p.Name) != nil { + fmt.Fprintf(errOut, "tfg: the tool %s declares a setting called %q and that is already a flag of this command. "+ + "This is a fault in the build rather than in what you typed.\n", d.ID, p.Name) + return ExitRuntime + } + fs.String(p.Name, "", parameterUsage(p)) + } + given, err := parseAround(fs, args) + if err != nil { + return ExitUsage + } + if len(given) > len(d.Inputs) { + fmt.Fprintf(errOut, "tfg: %s works on %s and was given %d. Run it once for each, for example: %s\n", + d.ID, inputCount(d), len(given), toolExample(d)) + return ExitUsage + } + + req := tool.Request{Inputs: map[string]string{}, Values: map[string]string{}} + for i, path := range given { + req.Inputs[d.Inputs[i].Name] = path + } + fs.Visit(func(f *flag.Flag) { + if f.Name != "json" { + req.Values[f.Name] = f.Value.String() + } + }) + + result, err := d.Start(ctx, req, nil) + if err != nil { + fmt.Fprintf(errOut, "tfg: %s\n", describeError(err)) + return classify(err) + } + return renderToolResult(d, result, *asJSON, out, errOut) +} + +// inputCount is how many things a tool works on, in words. +func inputCount(d tool.Descriptor) string { + if len(d.Inputs) == 1 { + return "one " + string(d.Inputs[0].Kind) + } + return fmt.Sprintf("%d inputs", len(d.Inputs)) +} + +// parseAround reads flags wherever they stand among the paths. +// +// The flag package stops at the first word that is not a flag, so "tfg tool +// checksum file --expected X" would leave the flag unread and take it for a +// second file. Both orders are in circulation - onePath says the same about +// the other commands - so this parses again after each run of paths. A "--" +// ends the flags for good, which is how a file whose name starts with a dash +// is reached. +// +// A lone "-" is a path like any other - the flag package leaves it where it +// is, since it is not a flag - and it is taken as one, the same as onePath +// takes it for the other commands. Until 2026-09-30 it was handed back to the +// flag package, which left it again, for ever: "tfg tool checksum -" never +// returned (a review of #157 found it). +func parseAround(fs *flag.FlagSet, args []string) ([]string, error) { + var paths []string + for { + for len(args) > 0 && (args[0] == "-" || !strings.HasPrefix(args[0], "-")) { + paths = append(paths, args[0]) + args = args[1:] + } + if len(args) == 0 { + return paths, nil + } + if err := fs.Parse(args); err != nil { + return nil, err + } + used := len(args) - fs.NArg() + if used > 0 && args[used-1] == "--" { + return append(paths, fs.Args()...), nil + } + args = fs.Args() + } +} + +// renderToolResult prints what a tool found. +// +// A mismatch is a failure with its own code, and a failed run writes nothing +// to standard output - the same as verify - so the whole answer goes to the +// error channel, JSON included. +func renderToolResult(d tool.Descriptor, r tool.Result, asJSON bool, out, errOut io.Writer) int { + failed := r.Verdict.Outcome == tool.Mismatch + w := out + if failed { + w = errOut + } + if asJSON { + if code := renderJSON(r.Data, w, errOut); code != ExitOK { + return code + } + } else { + printToolTable(d.Columns, r.Rows, w) + if said := r.Verdict.Said(); said != "" { + fmt.Fprintf(w, "\n%s\n", said) + } + } + if failed { + return ExitVerify + } + return ExitOK +} + +// printToolTable prints a result as columns under their headings, each as wide +// as its longest cell. +func printToolTable(columns []string, rows [][]string, w io.Writer) { + widths := make([]int, len(columns)) + for i, c := range columns { + widths[i] = len(c) + } + for _, row := range rows { + for i := 0; i < len(row) && i < len(widths); i++ { + widths[i] = max(widths[i], len(row[i])) + } + } + headings := make([]string, len(columns)) + for i, c := range columns { + headings[i] = strings.ToUpper(c) + } + printToolRow(headings, widths, w) + for _, row := range rows { + printToolRow(row, widths, w) + } +} + +// printToolRow prints one row of a table, every cell but the last padded to +// the width of its column. The last is left as it is, so a line ends where +// its words do. +func printToolRow(cells []string, widths []int, w io.Writer) { + padded := make([]string, len(cells)) + for i, cell := range cells { + padded[i] = cell + if i < len(cells)-1 { + padded[i] += strings.Repeat(" ", widths[i]-len(cell)) + } + } + fmt.Fprintln(w, strings.Join(padded, " ")) +} diff --git a/internal/format/stated.go b/internal/format/stated.go new file mode 100644 index 00000000..0874cbdb --- /dev/null +++ b/internal/format/stated.go @@ -0,0 +1,53 @@ +package format + +import "sort" + +// CheckStated is every problem with the values stated for a list of declared +// settings that belongs to something other than a format - a tool, today. +// +// CheckEachProperty, a damage's CheckEach and a preset's own check are three +// copies of this already (O261), and a tool would have been the fourth. The +// refusals are the same two types, so a form puts them under the box they are +// about and the command line ends with the same code, whoever declared the +// setting. owner fills the field those types call Format. +// +// In a file of its own since 2026-09-30, when format.go went past the ceiling +// on the length of a file. It is the one part of the package that is not +// about a format. +func CheckStated(owner string, declared []Property, stated map[string]string) []error { + known := make(map[string]Property, len(declared)) + names := make([]string, 0, len(declared)) + for _, p := range declared { + known[p.Name] = p + names = append(names, p.Name) + } + keys := make([]string, 0, len(stated)) + for k := range stated { + keys = append(keys, k) + } + sort.Strings(keys) + + var bad []error + for _, k := range keys { + if err := statedProblem(owner, known, names, k, stated[k]); err != nil { + bad = append(bad, err) + } + } + return bad +} + +// statedProblem is what is wrong with one stated value, or nil. +func statedProblem(owner string, known map[string]Property, names []string, key, value string) error { + p, ok := known[key] + if !ok { + return &UnknownPropertyError{Format: owner, Key: key, Known: names} + } + // Not stated, the same as left out - see CheckEachProperty. + if value == "" { + return nil + } + if why := p.Allows(value); why != "" { + return &PropertyValueError{Format: owner, Key: key, Value: value, Reason: why, Remedy: p.Instead()} + } + return nil +} diff --git a/internal/guard/concurrency_test.go b/internal/guard/concurrency_test.go index 8c847aeb..b2f13e80 100644 --- a/internal/guard/concurrency_test.go +++ b/internal/guard/concurrency_test.go @@ -34,6 +34,14 @@ var mayBeConcurrent = map[string]string{ // 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", + // The third registry, the tools, for the same reason as the two above. + "internal/tool/tool.go": "the tool registry is written at init and read by everything after", + // A tool reads a file of any size, and a window that waits for it is a + // window the desktop calls not responding - the reason the run of the + // engine happens beside the window, one file over. Closing the window + // cancels it and waits for it the same way (G7). Added 2026-09-29 with the + // Tools tab (docs/NARZEDZIA-SUMY-2026-09-29.md section 12.3). + "internal/gui/window/tools.go": "a tool reads beside the window, and closing the window waits for it", // 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/consolereach_test.go b/internal/guard/consolereach_test.go index 45ee7f62..ac0b96b1 100644 --- a/internal/guard/consolereach_test.go +++ b/internal/guard/consolereach_test.go @@ -37,11 +37,11 @@ import ( // is a guard that reads what the compiler reads. oracle is test only and out of // the layer map anyway, but the next embedded script will not be. func TestNothingBelowASurfaceReachesTheConsoleDirectly(t *testing.T) { - // Layer 4 is the surface - internal/cli and the window - and layer 5 is a - // main package. Those own the console by construction. Everything at 3 or - // below is a library, and a library that prints has taken a decision that - // belongs to whoever called it. - const surface = 4 + // surfaceLayer is internal/cli and the window, and above it is a main + // package. Those own the console by construction. Everything below is a + // library - the tools too - and a library that prints has taken a decision + // that belongs to whoever called it. + const surface = surfaceLayer var offenders []string packagesRead, filesRead := 0, 0 diff --git a/internal/guard/flagnames_test.go b/internal/guard/flagnames_test.go index f0e9503a..3a80f7a2 100644 --- a/internal/guard/flagnames_test.go +++ b/internal/guard/flagnames_test.go @@ -51,9 +51,9 @@ import ( func packagesBelowTheSurfaces(t *testing.T) []string { t.Helper() - // The surfaces are layer 4. Anything above them is a binary, anything - // below is what both of them show. - const surfaces = 4 + // Anything above the surfaces is a binary, anything below is what both of + // them show. + const surfaces = surfaceLayer var out []string for pkg, n := range layer { diff --git a/internal/guard/formhands_test.go b/internal/guard/formhands_test.go new file mode 100644 index 00000000..5f53f5b7 --- /dev/null +++ b/internal/guard/formhands_test.go @@ -0,0 +1,147 @@ +package guard + +import ( + "testing" + + "fyne.io/fyne/v2" + "fyne.io/fyne/v2/test" + + "github.com/donislawdev/TestingFilesGenerator/internal/gui/parts" + "github.com/donislawdev/TestingFilesGenerator/internal/gui/text" +) + +// The hands a guard works a screen with: fill a box, press a button, flip a +// switch, pick from a menu. Moved out of screenpixels_test.go on 2026-09-30, +// when that file went past the ceiling on the length of a test file - they +// are used by guards about every screen, not only by the one comparing +// pictures. + +func fillField(t *testing.T, o fyne.CanvasObject, label, value string) { + t.Helper() + entryUnder(t, o, label).SetText(value) +} + +// pressNamed calls the handler rather than tapping through the canvas, and that +// is deliberate here. Whether a button can really be reached is asked by +// TestEveryButtonAPersonCanSeeIsReallyPressable, which taps for real. This one +// is asking what the screen LOOKS like afterwards, so it wants the state and +// not the hit test - and a tap that missed would leave this guard comparing a +// screen in the wrong state against a picture, which reads as a rendering +// defect and is not one. +func pressNamed(t *testing.T, o fyne.CanvasObject, name string) { + t.Helper() + b := buttonNamed(o, name) + if b == nil { + t.Fatalf("there is no %q button on this screen", name) + } + if b.Disabled() { + t.Fatalf("the %q button is disabled, so this state cannot be reached", name) + } + b.OnTapped() +} + +// flipSwitch turns a switch off the way a person does, through the canvas. +// +// Tapping rather than calling SetChecked, and the difference is visible: a tap +// also takes focus - widget.Check.Tapped calls focusIfNotMobile - so a switch +// set in code draws no focus ring and a switch pressed by somebody does. The +// picture would differ from the screen, which is the one thing this guard must +// never do. +// +// The point is inside MinSize rather than the middle of the widget, because +// Check.Tapped ignores anything past its MinSize width and our switches are +// stretched to the width of the column. tools/probes/guirender presses the same +// way for the same reason. +func flipSwitch(t *testing.T, c fyne.Canvas, o fyne.CanvasObject, label string) { + t.Helper() + box := checkNamed(o, label) + if box == nil { + t.Fatalf("there is no switch labelled %q on this screen", label) + } + before := box.Checked + + at := fyne.CurrentApp().Driver().AbsolutePositionForObject(box) + active := box.MinSize() + test.TapCanvas(c, at.Add(fyne.NewPos(active.Width/2, box.Size().Height/2))) + + if box.Checked == before { + t.Fatalf("a press on the switch %q did not change it, so this state was never built.\n"+ + "Reason: it is %gx%g at %v and the press reached something else.\n"+ + "What to do: check whether the switch moved behind another control or off the laid out area.", + label, box.Size().Width, box.Size().Height, at) + } +} + +func chooserFor(t *testing.T, o fyne.CanvasObject) *parts.Chooser { + t.Helper() + chooser, ok := controlUnder(o, text.FieldFormat()).(*parts.Chooser) + if !ok { + t.Fatal("there is no format menu on this screen") + } + return chooser +} + +func chooseFormat(t *testing.T, o fyne.CanvasObject, format string) { + t.Helper() + chooserFor(t, o).SetSelected(format) +} + +// menuUnder is the list under any labelled field, on any screen. +// +// chooserFor asks for the format menu by name and only one screen has one. This +// is what the preset screen's own two lists needed, and not having it is why +// neither had ever been photographed. +func menuUnder(t *testing.T, o fyne.CanvasObject, label string) *parts.Chooser { + t.Helper() + chooser, ok := controlUnder(o, label).(*parts.Chooser) + if !ok { + t.Fatalf("there is no menu under %q on this screen", label) + } + return chooser +} + +// chooseWithThePointer picks a value the way somebody with a mouse does. +// +// The press is what matters and not the value. Since 2026-08-18 a press moves +// the keyboard into the control WITHOUT drawing the mark that says so, and +// calling SetSelected on its own never presses anything - so a scene built that +// way photographs the keyboard path and says nothing about the one that was +// reported twice from the screen. +// +// The list is taken away afterwards because that is what happens: the item that +// sets the value is inside the popup, and pressing it closes the popup behind +// itself. +func chooseWithThePointer(t *testing.T, c fyne.Canvas, o fyne.CanvasObject, format string) { + t.Helper() + chooser := chooserFor(t, o) + chooser.Tapped(&fyne.PointEvent{}) + chooser.SetSelected(format) + if top := c.Overlays().Top(); top != nil { + c.Overlays().Remove(top) + } +} + +// explanationBeside finds the question mark button that sits next to a field. +func explanationBeside(t *testing.T, o fyne.CanvasObject, label string) *parts.DetailButton { + t.Helper() + var found *parts.DetailButton + walk(o, func(obj fyne.CanvasObject) { + row, ok := obj.(*fyne.Container) + if !ok || len(row.Objects) < 2 || found != nil { + return + } + head, named := wordsOf(row.Objects[0]) + if !named || head != label { + return + } + // Searched, not indexed - see detailButtonIn. A field that has to be + // filled in carries a star between its name and this button. + if b := detailButtonIn(row); b != nil { + found = b + } + }) + if found == nil { + t.Fatalf("there is no explanation button beside %q", label) + } + return found +} diff --git a/internal/guard/guitext_test.go b/internal/guard/guitext_test.go index a7aa8474..012cf861 100644 --- a/internal/guard/guitext_test.go +++ b/internal/guard/guitext_test.go @@ -102,6 +102,10 @@ var notWords = map[string]string{ `"generate"`: "what the single batch screen is, the key it is known by between screens, never shown", `"presets"`: "what the presets screen is, the key it is known by between screens, never shown", `"recipe"`: "what the several batches screen is, the key it is known by between screens, never shown", + `"tools"`: "what the tools screen is, the key it is known by between screens, never shown", + `"tool"`: "the key the box choosing a tool and its section are known by, never shown", + `"tool-input"`: "the key the section of what a tool works on is remembered under, never shown", + `"tool-result"`: "the key the section of a tool's result is remembered under, never shown", `"preferences"`: "what the preferences screen is, the key it is known by between screens, never shown", `"about"`: "what the about screen is, the key it is known by between screens, never shown", `"file"`: "the scheme of a local address, which is a protocol name rather than prose", diff --git a/internal/guard/help_test.go b/internal/guard/help_test.go index 96d4bc5d..40b4841c 100644 --- a/internal/guard/help_test.go +++ b/internal/guard/help_test.go @@ -42,6 +42,10 @@ var commandsTakingHelp = [][]string{ {"preset", "list"}, {"preset", "show"}, {"preset", "eject"}, + {"tool"}, + {"tool", "list"}, + {"tool", "show"}, + {"tool", "checksum"}, } func TestAskingForHelpIsNotAMistake(t *testing.T) { diff --git a/internal/guard/keyboard_test.go b/internal/guard/keyboard_test.go index 5ec6fab0..560a6e90 100644 --- a/internal/guard/keyboard_test.go +++ b/internal/guard/keyboard_test.go @@ -12,6 +12,7 @@ import ( "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/tool/checksum" ) // The keyboard, and the one thing about it that is not obvious. @@ -109,6 +110,42 @@ func TestTheKeyboardStartsARunFromInsideABox(t *testing.T) { } } +// TestTheKeyboardRunsAToolFromInsideItsBox is the guard above for the Tools +// tab: Ctrl+Enter with the keyboard in the box naming the file runs the tool, +// and nothing else. +// +// Two defects, both until 2026-09-30. The boxes were handed the window's +// shortcuts from a list of three screens of its own, and the Tools tab was not +// on it, so the key did nothing. And the keyboard acted on the last screen +// that had a directory rather than on the tab on show, so once the boxes did +// pass it on, the key ran Generate on the first screen, which nobody could +// see. That is why the first screen is given a run it could make: a key that +// went there would write into this directory. +func TestTheKeyboardRunsAToolFromInsideItsBox(t *testing.T) { + path := writeTemp(t, "keyed.txt", "abc") + host, content, _ := keyedWindow(t) + dir := t.TempDir() + first := selectTab(t, content, text.TabOneTarget()) + entryUnder(t, first, text.FieldOutputDir()).SetText(dir) + entryUnder(t, first, text.FieldSize()).SetText("1kb") + entryUnder(t, first, text.FieldTargetID()).SetText("keys") + + screen := selectTab(t, content, text.TabTools()) + box := entryUnder(t, screen, text.SettingLabel(checksum.InputFile)) + box.SetText(path) + pressInABox(t, box, fyne.KeyReturn, fyne.KeyModifierControl) + join(host) + + if got := len(filesIn(t, dir)); got != 0 { + t.Errorf("Ctrl+Enter was pressed on the Tools tab and the first screen, which nobody could see, "+ + "wrote %d file(s)", got) + } + if want := publishedAnswer(t, "abc", "sha256"); !strings.Contains(allText(screen), want) { + t.Errorf("Ctrl+Enter was pressed in the box naming the file and the screen does not show its "+ + "sha256 %s - the shortcut never reached the tool", want) + } +} + // TestThePreviewShortcutAsksWithoutWriting is the other half: the same road, // and nothing on the disk at the end of it. func TestThePreviewShortcutAsksWithoutWriting(t *testing.T) { diff --git a/internal/guard/layers_test.go b/internal/guard/layers_test.go index db6fa2dc..aac3cc98 100644 --- a/internal/guard/layers_test.go +++ b/internal/guard/layers_test.go @@ -10,6 +10,10 @@ import ( // // Nothing points upwards. The engine must not learn about the command line, // and the command line binary must not learn about the window. +// +// The tools sit on a layer of their own between the engine and the surfaces, +// since 2026-09-29: a tool is a facade over the engine and the audit, so it +// has to see both, and both surfaces show every tool, so both have to see it. var layer = map[string]int{ "internal/version": 0, "internal/core": 0, @@ -31,7 +35,7 @@ var layer = map[string]int{ // import it and it cannot reach a binary by accident. It sits with the // other commands because it imports the registry and the version, and // because it is a program somebody runs rather than a library. - "internal/legal/cmd/sbom": 5, + "internal/legal/cmd/sbom": 6, "internal/format": 1, "internal/format/all": 1, @@ -86,19 +90,30 @@ var layer = map[string]int{ "internal/engine": 3, "internal/audit": 3, - "internal/cli": 4, - "internal/gui": 4, - "internal/gui/parts": 4, - "internal/gui/icon": 4, - "internal/gui/font": 4, - "internal/gui/catalogue": 4, - "internal/gui/text": 4, - "internal/gui/window": 4, + "internal/tool": 4, + "internal/tool/checksum": 4, + "internal/tool/all": 4, + + "internal/cli": 5, + "internal/gui": 5, + "internal/gui/parts": 5, + "internal/gui/icon": 5, + "internal/gui/font": 5, + "internal/gui/catalogue": 5, + "internal/gui/text": 5, + "internal/gui/window": 5, - "cmd/tfg": 5, - "cmd/tfg-gui": 5, + "cmd/tfg": 6, + "cmd/tfg-gui": 6, } +// surfaceLayer is the layer of the command line and the window. Below it is a +// library both of them show, above it a binary. Three guards ask this - what +// may print, what may reach the network, where flag names are read - and each +// used to hold its own 4, so moving the surfaces up a layer was three edits +// with nothing saying they belonged together. +const surfaceLayer = 5 + // Same layer edges that are intended. Everything else inside one layer is a // violation, which is what keeps cli and gui apart. var sameLayerAllowed = map[string][]string{ @@ -166,6 +181,11 @@ var sameLayerAllowed = map[string][]string{ "internal/format/wav": {"internal/format", "internal/format/imagelabel"}, "internal/preset": {"internal/recipe"}, + // A tool registers itself with the registry beside it, and the package that + // pulls every tool in reaches each of them - the arrangement of the formats. + "internal/tool/checksum": {"internal/tool"}, + "internal/tool/all": {"internal/tool/checksum"}, + // 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 diff --git a/internal/guard/mutationcoverage_test.go b/internal/guard/mutationcoverage_test.go index 470ccd1d..8cc246d4 100644 --- a/internal/guard/mutationcoverage_test.go +++ b/internal/guard/mutationcoverage_test.go @@ -32,6 +32,17 @@ import ( // means saying out loud that a guard is unproven. The list should only ever get // shorter. var notProvenByMutation = map[string]bool{ + // A pipe exists only off Windows, so this guard compiles only there, and + // the mutation runner runs on the Windows machine the project is built on + // - the substitution would be applied and the test would not exist to + // catch it. It runs on every Linux and macOS runner of the CI. The two + // breaks it is for - asking IsDir where IsRegular stands in + // checksum.openRegular, and an open without O_NONBLOCK in open_unix.go. + // The first was read, not run (2026-09-29). The second was run by hand in + // a Linux container on 2026-09-30, through the tool rather than through + // this test: without the flag a pipe was still waited on after five + // seconds, and with it refused at once. + "TestAToolNeverWaitsOnAPipe": true, // A directory entry costs one tar block and nothing more. // // There is no line of ours under this one to break. It asserts what diff --git a/internal/guard/navigation_test.go b/internal/guard/navigation_test.go index 0b73fdc7..46a3a9d0 100644 --- a/internal/guard/navigation_test.go +++ b/internal/guard/navigation_test.go @@ -113,7 +113,7 @@ func TestMovingBetweenScreensIsTabsAndNotButtons(t *testing.T) { // screens that produce files come first, the recipe screen joins them, // Preferences follows the work because it changes nothing a run makes, and // the notice is the one somebody goes looking for rather than passes through. - want := []string{text.TabOneTarget(), text.TabPresets(), text.TabRecipe(), text.TabPreferences(), text.TabAbout()} + want := []string{text.TabOneTarget(), text.TabPresets(), text.TabRecipe(), text.TabTools(), text.TabPreferences(), text.TabAbout()} got := tabNames(host.content) if len(got) != len(want) { t.Fatalf("the window has tabs %v and %v was expected", got, want) diff --git a/internal/guard/network_test.go b/internal/guard/network_test.go index 4db27930..1d861960 100644 --- a/internal/guard/network_test.go +++ b/internal/guard/network_test.go @@ -23,7 +23,7 @@ func networkBanned(rel string) bool { return false } if l, ok := layer[rel]; ok { - return l <= 3 || rel == "internal/cli" || rel == "cmd/tfg" + return l < surfaceLayer || rel == "internal/cli" || rel == "cmd/tfg" } return false } diff --git a/internal/guard/parity_test.go b/internal/guard/parity_test.go index 8483e3fd..ca0548d4 100644 --- a/internal/guard/parity_test.go +++ b/internal/guard/parity_test.go @@ -9,6 +9,8 @@ import ( _ "github.com/donislawdev/TestingFilesGenerator/internal/format/all" "github.com/donislawdev/TestingFilesGenerator/internal/preset" "github.com/donislawdev/TestingFilesGenerator/internal/recipe" + "github.com/donislawdev/TestingFilesGenerator/internal/tool" + _ "github.com/donislawdev/TestingFilesGenerator/internal/tool/all" ) // D1 says every capability of the engine is reachable from both surfaces, and @@ -278,6 +280,15 @@ var reachableFromTheWindow = []string{ "recipe:defaults.label", "recipe:output", "recipe:output.manifest", + + // The tools. Every tool, what it works on and every setting it takes are + // drawn from the declaration on the Tools tab - held by + // TestTheToolsScreenOffersEveryToolWithEveryBox, which walks the registry + // rather than this list, and by TestTheWindowRunsAToolAndSaysWhatTheCommandLineSays. + "tool:checksum", + "tool:checksum.algorithm", + "tool:checksum.expected", + "tool:checksum.file", } // notYetReachable is everything the engine can do that the window cannot. @@ -367,6 +378,18 @@ func capabilities() []string { out = append(out, "preset:"+p.ID+"."+name) } } + // A tool is a capability as well, and so is everything it works on and + // takes: a tool the command line runs and the window cannot is D1 broken + // the day it is registered. + for _, d := range tool.All() { + out = append(out, "tool:"+d.ID) + for _, in := range d.Inputs { + out = append(out, "tool:"+d.ID+"."+in.Name) + } + for _, p := range d.Settings { + out = append(out, "tool:"+d.ID+"."+p.Name) + } + } sort.Strings(out) return out } diff --git a/internal/guard/presetwindow_test.go b/internal/guard/presetwindow_test.go index 77d71447..df5dd0cf 100644 --- a/internal/guard/presetwindow_test.go +++ b/internal/guard/presetwindow_test.go @@ -526,16 +526,24 @@ func TestWhatAPresetFindsIsShownAsSeparateLines(t *testing.T) { // than the dialog: that the button asks, and that what comes back is what the // run will use. A button that asks and drops the answer looks exactly like one // that works, which is the shape this project keeps meeting. +// +// The button is pressed on the screen's own tab, not found in the whole +// window. It was found in the whole window until 2026-09-30, and the Tools tab +// brought a second "Choose..." - the last one found, so this pressed the +// file picker of the Tools tab and failed for a reason that was not the +// directory at all. The twin below passed for the same reason and proved +// nothing (O118). func TestBrowsingForADirectoryPutsItInTheField(t *testing.T) { host := newFakeHost(t) host.picked = filepath.Join(t.TempDir(), "chosen") window.Open(host) - for _, screenName := range []string{"generate", "preset"} { - if screenName == "preset" { - selectTab(t, host.content, text.TabPresets()) - } - content := host.content + for _, screen := range []struct{ name, tab string }{ + {"generate", text.TabOneTarget()}, + {"preset", text.TabPresets()}, + } { + content := selectTab(t, host.content, screen.tab) + screenName := screen.name // Put something else in first, so this asks whether the button fills // the field rather than whether the field happens to differ. Since @@ -544,9 +552,10 @@ func TestBrowsingForADirectoryPutsItInTheField(t *testing.T) { // and comparing against that measured the carrying, not the button. fill(t, content, text.FieldOutputDir(), "C:\\neither\\of\\them") before := entryUnder(t, content, text.FieldOutputDir()).Text + asked := host.asked press(t, content, "Choose...") - if host.asked == 0 { + if host.asked == asked { t.Fatalf("the %s screen has a browse button that asks nobody", screenName) } after := entryUnder(t, content, text.FieldOutputDir()).Text @@ -567,11 +576,17 @@ func TestBrowsingForADirectoryPutsItInTheField(t *testing.T) { func TestCancellingTheDirectoryPickerLeavesTheFieldAlone(t *testing.T) { host := newFakeHost(t) // picked is empty, so nothing is chosen window.Open(host) + screen := selectTab(t, host.content, text.TabOneTarget()) - before := entryUnder(t, host.content, text.FieldOutputDir()).Text - press(t, host.content, "Choose...") + before := entryUnder(t, screen, text.FieldOutputDir()).Text + press(t, screen, "Choose...") - if after := entryUnder(t, host.content, text.FieldOutputDir()).Text; after != before { + // Asserted rather than assumed: this guard passed while the press went to + // another tab's button, which asked nothing of the directory picker. + if host.asked == 0 { + t.Fatal("the browse button asked nobody, so there was no picker to cancel") + } + if after := entryUnder(t, screen, text.FieldOutputDir()).Text; after != before { t.Errorf("cancelling the picker changed the field from %q to %q", before, after) } } diff --git a/internal/guard/reachability_test.go b/internal/guard/reachability_test.go index cb9b1ae8..20cfe525 100644 --- a/internal/guard/reachability_test.go +++ b/internal/guard/reachability_test.go @@ -57,7 +57,7 @@ func laidOutWindow(t *testing.T) (fyne.CanvasObject, fyne.Canvas) { // looking at three of the four. Passing by not looking is worse than failing. // TestEveryTabInTheWindowIsOnTheListGuardsWalk keeps it honest. func allTabs() []string { - return []string{text.TabOneTarget(), text.TabPresets(), text.TabRecipe(), text.TabPreferences(), text.TabAbout()} + return []string{text.TabOneTarget(), text.TabPresets(), text.TabRecipe(), text.TabTools(), text.TabPreferences(), text.TabAbout()} } // What this defends. A button a person can see is a button a person can press. diff --git a/internal/guard/registrywords_test.go b/internal/guard/registrywords_test.go index 6c7385f2..3c59d073 100644 --- a/internal/guard/registrywords_test.go +++ b/internal/guard/registrywords_test.go @@ -23,6 +23,7 @@ import ( "github.com/donislawdev/TestingFilesGenerator/internal/gui/text" "github.com/donislawdev/TestingFilesGenerator/internal/gui/window" "github.com/donislawdev/TestingFilesGenerator/internal/preset" + "github.com/donislawdev/TestingFilesGenerator/internal/tool" ) // The words of the registries in the window's language - @@ -240,6 +241,9 @@ func TestTheWindowDescribesASettingAsTheCommandLineDoes(t *testing.T) { for _, d := range damage.All() { each(text.DamageOwner(d.ID), d.Parameters) } + for _, d := range tool.All() { + each(text.ToolOwner(d.ID), d.Settings) + } for _, n := range []int64{0, 1023, 1024, 1536, 10 << 20, 2516582400, 1 << 40} { if got, want := text.HumanBytes(n), core.HumanBytes(n); got != want { t.Errorf("%d bytes: the window says %q and the command line %q", n, got, want) diff --git a/internal/guard/screenpixels_test.go b/internal/guard/screenpixels_test.go index e84a586b..b04b1611 100644 --- a/internal/guard/screenpixels_test.go +++ b/internal/guard/screenpixels_test.go @@ -319,6 +319,22 @@ func screenScenes() []screenScene { {name: "preferences-chosen", tab: text.TabPreferences(), set: func(t *testing.T, s scene) { menuUnder(t, s.tab, text.FieldLanguage()).SetSelected(polishName(t)) }}, + // The Tools tab at rest, refused for want of a file, and with a result + // that matches - the four states of GUI rule 3 but the one in flight, + // which the busy face of the work screens already pictures. The file is + // named by a path relative to this package, so the picture does not + // carry a temporary directory that differs on every run, and with a + // forward slash, which Windows reads as well - a separator of the + // system would draw a different box on each runner. + {name: "tools", tab: text.TabTools()}, + {name: "tools-refused", tab: text.TabTools(), set: func(t *testing.T, s scene) { + pressNamed(t, s.tab, text.ButtonRunTool()) + }}, + {name: "tools-result", tab: text.TabTools(), set: func(t *testing.T, s scene) { + fillField(t, s.tab, text.SettingLabel("file"), "testdata/checksum-sample.txt") + fillField(t, s.tab, text.SettingLabel("expected"), "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad") + pressNamed(t, s.tab, text.ButtonRunTool()) + }}, {name: "generate", tab: text.TabOneTarget()}, {name: "generate-empty", tab: text.TabOneTarget(), set: func(t *testing.T, s scene) { fillField(t, s.tab, text.FieldCount(), "0") @@ -900,136 +916,6 @@ func pinOutputDirectory(tab fyne.CanvasObject) { }) } -func fillField(t *testing.T, o fyne.CanvasObject, label, value string) { - t.Helper() - entryUnder(t, o, label).SetText(value) -} - -// pressNamed calls the handler rather than tapping through the canvas, and that -// is deliberate here. Whether a button can really be reached is asked by -// TestEveryButtonAPersonCanSeeIsReallyPressable, which taps for real. This one -// is asking what the screen LOOKS like afterwards, so it wants the state and -// not the hit test - and a tap that missed would leave this guard comparing a -// screen in the wrong state against a picture, which reads as a rendering -// defect and is not one. -func pressNamed(t *testing.T, o fyne.CanvasObject, name string) { - t.Helper() - b := buttonNamed(o, name) - if b == nil { - t.Fatalf("there is no %q button on this screen", name) - } - if b.Disabled() { - t.Fatalf("the %q button is disabled, so this state cannot be reached", name) - } - b.OnTapped() -} - -// flipSwitch turns a switch off the way a person does, through the canvas. -// -// Tapping rather than calling SetChecked, and the difference is visible: a tap -// also takes focus - widget.Check.Tapped calls focusIfNotMobile - so a switch -// set in code draws no focus ring and a switch pressed by somebody does. The -// picture would differ from the screen, which is the one thing this guard must -// never do. -// -// The point is inside MinSize rather than the middle of the widget, because -// Check.Tapped ignores anything past its MinSize width and our switches are -// stretched to the width of the column. tools/probes/guirender presses the same -// way for the same reason. -func flipSwitch(t *testing.T, c fyne.Canvas, o fyne.CanvasObject, label string) { - t.Helper() - box := checkNamed(o, label) - if box == nil { - t.Fatalf("there is no switch labelled %q on this screen", label) - } - before := box.Checked - - at := fyne.CurrentApp().Driver().AbsolutePositionForObject(box) - active := box.MinSize() - test.TapCanvas(c, at.Add(fyne.NewPos(active.Width/2, box.Size().Height/2))) - - if box.Checked == before { - t.Fatalf("a press on the switch %q did not change it, so this state was never built.\n"+ - "Reason: it is %gx%g at %v and the press reached something else.\n"+ - "What to do: check whether the switch moved behind another control or off the laid out area.", - label, box.Size().Width, box.Size().Height, at) - } -} - -func chooserFor(t *testing.T, o fyne.CanvasObject) *parts.Chooser { - t.Helper() - chooser, ok := controlUnder(o, text.FieldFormat()).(*parts.Chooser) - if !ok { - t.Fatal("there is no format menu on this screen") - } - return chooser -} - -func chooseFormat(t *testing.T, o fyne.CanvasObject, format string) { - t.Helper() - chooserFor(t, o).SetSelected(format) -} - -// menuUnder is the list under any labelled field, on any screen. -// -// chooserFor asks for the format menu by name and only one screen has one. This -// is what the preset screen's own two lists needed, and not having it is why -// neither had ever been photographed. -func menuUnder(t *testing.T, o fyne.CanvasObject, label string) *parts.Chooser { - t.Helper() - chooser, ok := controlUnder(o, label).(*parts.Chooser) - if !ok { - t.Fatalf("there is no menu under %q on this screen", label) - } - return chooser -} - -// chooseWithThePointer picks a value the way somebody with a mouse does. -// -// The press is what matters and not the value. Since 2026-08-18 a press moves -// the keyboard into the control WITHOUT drawing the mark that says so, and -// calling SetSelected on its own never presses anything - so a scene built that -// way photographs the keyboard path and says nothing about the one that was -// reported twice from the screen. -// -// The list is taken away afterwards because that is what happens: the item that -// sets the value is inside the popup, and pressing it closes the popup behind -// itself. -func chooseWithThePointer(t *testing.T, c fyne.Canvas, o fyne.CanvasObject, format string) { - t.Helper() - chooser := chooserFor(t, o) - chooser.Tapped(&fyne.PointEvent{}) - chooser.SetSelected(format) - if top := c.Overlays().Top(); top != nil { - c.Overlays().Remove(top) - } -} - -// explanationBeside finds the question mark button that sits next to a field. -func explanationBeside(t *testing.T, o fyne.CanvasObject, label string) *parts.DetailButton { - t.Helper() - var found *parts.DetailButton - walk(o, func(obj fyne.CanvasObject) { - row, ok := obj.(*fyne.Container) - if !ok || len(row.Objects) < 2 || found != nil { - return - } - head, named := wordsOf(row.Objects[0]) - if !named || head != label { - return - } - // Searched, not indexed - see detailButtonIn. A field that has to be - // filled in carries a star between its name and this button. - if b := detailButtonIn(row); b != nil { - found = b - } - }) - if found == nil { - t.Fatalf("there is no explanation button beside %q", label) - } - return found -} - func compareAgainstReference(t *testing.T, name, reference string, got image.Image) { t.Helper() diff --git a/internal/guard/testdata/checksum-sample.txt b/internal/guard/testdata/checksum-sample.txt new file mode 100644 index 00000000..f2ba8f84 --- /dev/null +++ b/internal/guard/testdata/checksum-sample.txt @@ -0,0 +1 @@ +abc \ No newline at end of file diff --git a/internal/guard/testdata/screens/about.png b/internal/guard/testdata/screens/about.png index 94100cf1..0f6d9770 100644 Binary files a/internal/guard/testdata/screens/about.png and b/internal/guard/testdata/screens/about.png differ diff --git a/internal/guard/testdata/screens/about.xml b/internal/guard/testdata/screens/about.xml index da8df10f..ba6ccea5 100644 --- a/internal/guard/testdata/screens/about.xml +++ b/internal/guard/testdata/screens/about.xml @@ -20,17 +20,22 @@ Several batches - + + + + Tools + + Preferences - + About - + diff --git a/internal/guard/testdata/screens/generate-chosen-by-key.png b/internal/guard/testdata/screens/generate-chosen-by-key.png index 7c698f60..8c66b77f 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 5803c33f..053b9f95 100644 --- a/internal/guard/testdata/screens/generate-chosen-by-key.xml +++ b/internal/guard/testdata/screens/generate-chosen-by-key.xml @@ -20,12 +20,17 @@ Several batches - + + + + Tools + + Preferences - + About diff --git a/internal/guard/testdata/screens/generate-chosen.png b/internal/guard/testdata/screens/generate-chosen.png index 302b01a3..1e2f9a66 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 c2df00d8..32e866f7 100644 --- a/internal/guard/testdata/screens/generate-chosen.xml +++ b/internal/guard/testdata/screens/generate-chosen.xml @@ -20,12 +20,17 @@ Several batches - + + + + Tools + + Preferences - + About diff --git a/internal/guard/testdata/screens/generate-empty.png b/internal/guard/testdata/screens/generate-empty.png index 5fc52e68..ab61291c 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 840e63e7..37f46f9b 100644 --- a/internal/guard/testdata/screens/generate-empty.xml +++ b/internal/guard/testdata/screens/generate-empty.xml @@ -20,12 +20,17 @@ Several batches - + + + + Tools + + Preferences - + About diff --git a/internal/guard/testdata/screens/generate-focused.png b/internal/guard/testdata/screens/generate-focused.png index d38f627c..668a91b4 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 ec42ecff..1c96076f 100644 --- a/internal/guard/testdata/screens/generate-focused.xml +++ b/internal/guard/testdata/screens/generate-focused.xml @@ -20,12 +20,17 @@ Several batches - + + + + Tools + + Preferences - + About diff --git a/internal/guard/testdata/screens/generate-hovered.png b/internal/guard/testdata/screens/generate-hovered.png index 1ac3ae18..0b2410d6 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 82a1af08..91aa3555 100644 --- a/internal/guard/testdata/screens/generate-hovered.xml +++ b/internal/guard/testdata/screens/generate-hovered.xml @@ -20,12 +20,17 @@ Several batches - + + + + Tools + + Preferences - + About diff --git a/internal/guard/testdata/screens/generate-menu-hovered.png b/internal/guard/testdata/screens/generate-menu-hovered.png index f98b80e6..d0fa6b9b 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 a4f92426..a6c0385a 100644 --- a/internal/guard/testdata/screens/generate-menu-hovered.xml +++ b/internal/guard/testdata/screens/generate-menu-hovered.xml @@ -20,12 +20,17 @@ Several batches - + + + + Tools + + Preferences - + About diff --git a/internal/guard/testdata/screens/generate-menu-keyed.png b/internal/guard/testdata/screens/generate-menu-keyed.png index 76a90698..c0dbabdf 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 2eab3580..55430925 100644 --- a/internal/guard/testdata/screens/generate-menu-keyed.xml +++ b/internal/guard/testdata/screens/generate-menu-keyed.xml @@ -20,12 +20,17 @@ Several batches - + + + + Tools + + Preferences - + About diff --git a/internal/guard/testdata/screens/generate-menu.png b/internal/guard/testdata/screens/generate-menu.png index 215641bd..5951070f 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 070021ec..34743389 100644 --- a/internal/guard/testdata/screens/generate-menu.xml +++ b/internal/guard/testdata/screens/generate-menu.xml @@ -20,12 +20,17 @@ Several batches - + + + + Tools + + Preferences - + About diff --git a/internal/guard/testdata/screens/generate-pdf-settings.png b/internal/guard/testdata/screens/generate-pdf-settings.png index 3e3a537b..e5a24f5b 100644 Binary files a/internal/guard/testdata/screens/generate-pdf-settings.png and b/internal/guard/testdata/screens/generate-pdf-settings.png differ diff --git a/internal/guard/testdata/screens/generate-pdf-settings.xml b/internal/guard/testdata/screens/generate-pdf-settings.xml index 464140fa..8579e561 100644 --- a/internal/guard/testdata/screens/generate-pdf-settings.xml +++ b/internal/guard/testdata/screens/generate-pdf-settings.xml @@ -20,12 +20,17 @@ Several batches - + + + + Tools + + Preferences - + About diff --git a/internal/guard/testdata/screens/generate-refused-both.png b/internal/guard/testdata/screens/generate-refused-both.png index d565c944..be8c64a6 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 84902247..a88a9fdf 100644 --- a/internal/guard/testdata/screens/generate-refused-both.xml +++ b/internal/guard/testdata/screens/generate-refused-both.xml @@ -20,12 +20,17 @@ Several batches - + + + + Tools + + Preferences - + About diff --git a/internal/guard/testdata/screens/generate-refused-setting.png b/internal/guard/testdata/screens/generate-refused-setting.png index e3ff14e2..f4883410 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 406109cc..91c6a2cc 100644 --- a/internal/guard/testdata/screens/generate-refused-setting.xml +++ b/internal/guard/testdata/screens/generate-refused-setting.xml @@ -20,12 +20,17 @@ Several batches - + + + + Tools + + Preferences - + About diff --git a/internal/guard/testdata/screens/generate-refused.png b/internal/guard/testdata/screens/generate-refused.png index bafaac1f..b00cb420 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 565810be..e5d63d64 100644 --- a/internal/guard/testdata/screens/generate-refused.xml +++ b/internal/guard/testdata/screens/generate-refused.xml @@ -20,12 +20,17 @@ Several batches - + + + + Tools + + Preferences - + About diff --git a/internal/guard/testdata/screens/generate-switch-by-key.png b/internal/guard/testdata/screens/generate-switch-by-key.png index 16eed34b..95a49490 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 7a73a883..e70c8aa1 100644 --- a/internal/guard/testdata/screens/generate-switch-by-key.xml +++ b/internal/guard/testdata/screens/generate-switch-by-key.xml @@ -20,12 +20,17 @@ Several batches - + + + + Tools + + Preferences - + About diff --git a/internal/guard/testdata/screens/generate-typed.png b/internal/guard/testdata/screens/generate-typed.png index aa409394..e9f041c1 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 4ba8b473..3a5fb2ee 100644 --- a/internal/guard/testdata/screens/generate-typed.xml +++ b/internal/guard/testdata/screens/generate-typed.xml @@ -20,12 +20,17 @@ Several batches - + + + + Tools + + Preferences - + About diff --git a/internal/guard/testdata/screens/generate-unchecked.png b/internal/guard/testdata/screens/generate-unchecked.png index 28313db8..bfd063ce 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 40468838..869b133f 100644 --- a/internal/guard/testdata/screens/generate-unchecked.xml +++ b/internal/guard/testdata/screens/generate-unchecked.xml @@ -20,12 +20,17 @@ Several batches - + + + + Tools + + Preferences - + About diff --git a/internal/guard/testdata/screens/generate.png b/internal/guard/testdata/screens/generate.png index a69ba375..36d218a6 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 833545e6..0baaf856 100644 --- a/internal/guard/testdata/screens/generate.xml +++ b/internal/guard/testdata/screens/generate.xml @@ -20,12 +20,17 @@ Several batches - + + + + Tools + + Preferences - + About diff --git a/internal/guard/testdata/screens/preferences-chosen.png b/internal/guard/testdata/screens/preferences-chosen.png index 878afb64..1da4ac0a 100644 Binary files a/internal/guard/testdata/screens/preferences-chosen.png and b/internal/guard/testdata/screens/preferences-chosen.png differ diff --git a/internal/guard/testdata/screens/preferences-chosen.xml b/internal/guard/testdata/screens/preferences-chosen.xml index 45f19a2a..6e9a0f5e 100644 --- a/internal/guard/testdata/screens/preferences-chosen.xml +++ b/internal/guard/testdata/screens/preferences-chosen.xml @@ -20,17 +20,22 @@ Several batches - + + + + Tools + + Preferences - + About - + diff --git a/internal/guard/testdata/screens/preferences.png b/internal/guard/testdata/screens/preferences.png index 71a15be1..2cb78a3c 100644 Binary files a/internal/guard/testdata/screens/preferences.png and b/internal/guard/testdata/screens/preferences.png differ diff --git a/internal/guard/testdata/screens/preferences.xml b/internal/guard/testdata/screens/preferences.xml index b09ca547..a4df9a9d 100644 --- a/internal/guard/testdata/screens/preferences.xml +++ b/internal/guard/testdata/screens/preferences.xml @@ -20,17 +20,22 @@ Several batches - + + + + Tools + + Preferences - + About - + diff --git a/internal/guard/testdata/screens/preset-many-settings.png b/internal/guard/testdata/screens/preset-many-settings.png index 322227c7..42242b6c 100644 Binary files a/internal/guard/testdata/screens/preset-many-settings.png and b/internal/guard/testdata/screens/preset-many-settings.png differ diff --git a/internal/guard/testdata/screens/preset-many-settings.xml b/internal/guard/testdata/screens/preset-many-settings.xml index 18a1f5b9..2a0d389b 100644 --- a/internal/guard/testdata/screens/preset-many-settings.xml +++ b/internal/guard/testdata/screens/preset-many-settings.xml @@ -20,12 +20,17 @@ Several batches - + + + + Tools + + Preferences - + About diff --git a/internal/guard/testdata/screens/preset-menu-setting.png b/internal/guard/testdata/screens/preset-menu-setting.png index a1313903..97c39cb7 100644 Binary files a/internal/guard/testdata/screens/preset-menu-setting.png and b/internal/guard/testdata/screens/preset-menu-setting.png differ diff --git a/internal/guard/testdata/screens/preset-menu-setting.xml b/internal/guard/testdata/screens/preset-menu-setting.xml index 241f2fda..796d5705 100644 --- a/internal/guard/testdata/screens/preset-menu-setting.xml +++ b/internal/guard/testdata/screens/preset-menu-setting.xml @@ -20,12 +20,17 @@ Several batches - + + + + Tools + + Preferences - + About diff --git a/internal/guard/testdata/screens/preset-menu.png b/internal/guard/testdata/screens/preset-menu.png index 626e5e5e..e2be72e9 100644 Binary files a/internal/guard/testdata/screens/preset-menu.png and b/internal/guard/testdata/screens/preset-menu.png differ diff --git a/internal/guard/testdata/screens/preset-menu.xml b/internal/guard/testdata/screens/preset-menu.xml index 1a9f5649..d5a13d95 100644 --- a/internal/guard/testdata/screens/preset-menu.xml +++ b/internal/guard/testdata/screens/preset-menu.xml @@ -20,12 +20,17 @@ Several batches - + + + + Tools + + Preferences - + About diff --git a/internal/guard/testdata/screens/preset-refused.png b/internal/guard/testdata/screens/preset-refused.png index 1afc76f3..e28da35b 100644 Binary files a/internal/guard/testdata/screens/preset-refused.png and b/internal/guard/testdata/screens/preset-refused.png differ diff --git a/internal/guard/testdata/screens/preset-refused.xml b/internal/guard/testdata/screens/preset-refused.xml index f28e48cf..581be9db 100644 --- a/internal/guard/testdata/screens/preset-refused.xml +++ b/internal/guard/testdata/screens/preset-refused.xml @@ -20,12 +20,17 @@ Several batches - + + + + Tools + + Preferences - + About diff --git a/internal/guard/testdata/screens/preset.png b/internal/guard/testdata/screens/preset.png index 983311cf..b1d17bf5 100644 Binary files a/internal/guard/testdata/screens/preset.png and b/internal/guard/testdata/screens/preset.png differ diff --git a/internal/guard/testdata/screens/preset.xml b/internal/guard/testdata/screens/preset.xml index a18a6e27..0d0cabff 100644 --- a/internal/guard/testdata/screens/preset.xml +++ b/internal/guard/testdata/screens/preset.xml @@ -20,12 +20,17 @@ Several batches - + + + + Tools + + Preferences - + About diff --git a/internal/guard/testdata/screens/recipe-contents.png b/internal/guard/testdata/screens/recipe-contents.png index e84d0129..6d11ba59 100644 Binary files a/internal/guard/testdata/screens/recipe-contents.png and b/internal/guard/testdata/screens/recipe-contents.png differ diff --git a/internal/guard/testdata/screens/recipe-contents.xml b/internal/guard/testdata/screens/recipe-contents.xml index 3546ddd8..6b0392a4 100644 --- a/internal/guard/testdata/screens/recipe-contents.xml +++ b/internal/guard/testdata/screens/recipe-contents.xml @@ -20,12 +20,17 @@ Several batches - + + + + Tools + + Preferences - + About diff --git a/internal/guard/testdata/screens/recipe-on-a-preset.png b/internal/guard/testdata/screens/recipe-on-a-preset.png index a7fa5de7..80528550 100644 Binary files a/internal/guard/testdata/screens/recipe-on-a-preset.png and b/internal/guard/testdata/screens/recipe-on-a-preset.png differ diff --git a/internal/guard/testdata/screens/recipe-on-a-preset.xml b/internal/guard/testdata/screens/recipe-on-a-preset.xml index 0056217b..2ba290e5 100644 --- a/internal/guard/testdata/screens/recipe-on-a-preset.xml +++ b/internal/guard/testdata/screens/recipe-on-a-preset.xml @@ -20,12 +20,17 @@ Several batches - + + + + Tools + + Preferences - + About diff --git a/internal/guard/testdata/screens/recipe-refused-with-one-batch-filled.png b/internal/guard/testdata/screens/recipe-refused-with-one-batch-filled.png index 55d053fa..21346917 100644 Binary files a/internal/guard/testdata/screens/recipe-refused-with-one-batch-filled.png and b/internal/guard/testdata/screens/recipe-refused-with-one-batch-filled.png differ diff --git a/internal/guard/testdata/screens/recipe-refused-with-one-batch-filled.xml b/internal/guard/testdata/screens/recipe-refused-with-one-batch-filled.xml index 5d12c8ac..a43031f8 100644 --- a/internal/guard/testdata/screens/recipe-refused-with-one-batch-filled.xml +++ b/internal/guard/testdata/screens/recipe-refused-with-one-batch-filled.xml @@ -20,12 +20,17 @@ Several batches - + + + + Tools + + Preferences - + About diff --git a/internal/guard/testdata/screens/recipe-refused.png b/internal/guard/testdata/screens/recipe-refused.png index 766c4e60..ed38575b 100644 Binary files a/internal/guard/testdata/screens/recipe-refused.png and b/internal/guard/testdata/screens/recipe-refused.png differ diff --git a/internal/guard/testdata/screens/recipe-refused.xml b/internal/guard/testdata/screens/recipe-refused.xml index 7254e8d1..7aac111d 100644 --- a/internal/guard/testdata/screens/recipe-refused.xml +++ b/internal/guard/testdata/screens/recipe-refused.xml @@ -20,12 +20,17 @@ Several batches - + + + + Tools + + Preferences - + About diff --git a/internal/guard/testdata/screens/recipe-two-batches.png b/internal/guard/testdata/screens/recipe-two-batches.png index 2af13213..d8339a2b 100644 Binary files a/internal/guard/testdata/screens/recipe-two-batches.png and b/internal/guard/testdata/screens/recipe-two-batches.png differ diff --git a/internal/guard/testdata/screens/recipe-two-batches.xml b/internal/guard/testdata/screens/recipe-two-batches.xml index bf39469c..25a56a74 100644 --- a/internal/guard/testdata/screens/recipe-two-batches.xml +++ b/internal/guard/testdata/screens/recipe-two-batches.xml @@ -20,12 +20,17 @@ Several batches - + + + + Tools + + Preferences - + About diff --git a/internal/guard/testdata/screens/recipe.png b/internal/guard/testdata/screens/recipe.png index be994474..6b481207 100644 Binary files a/internal/guard/testdata/screens/recipe.png and b/internal/guard/testdata/screens/recipe.png differ diff --git a/internal/guard/testdata/screens/recipe.xml b/internal/guard/testdata/screens/recipe.xml index 7dd6fce5..6aabe93e 100644 --- a/internal/guard/testdata/screens/recipe.xml +++ b/internal/guard/testdata/screens/recipe.xml @@ -20,12 +20,17 @@ Several batches - + + + + Tools + + Preferences - + About diff --git a/internal/guard/testdata/screens/tools-refused.png b/internal/guard/testdata/screens/tools-refused.png new file mode 100644 index 00000000..d8c2b43c Binary files /dev/null and b/internal/guard/testdata/screens/tools-refused.png differ diff --git a/internal/guard/testdata/screens/tools-refused.xml b/internal/guard/testdata/screens/tools-refused.xml new file mode 100644 index 00000000..6a070d4f --- /dev/null +++ b/internal/guard/testdata/screens/tools-refused.xml @@ -0,0 +1,364 @@ + + + + + + + + + + + Single batch + + + + + Presets + + + + + Several batches + + + + + Tools + + + + + Preferences + + + + + About + + + + + + + + + + + + + + + + Tools + + + + + + + Small things to do with files you already have. Every tool here is on the command line too, as tfg tool. + + + + + + + + + + + + + + + + + + + + + + The question + + + + + + + + + + + + + + Tool + + + + + + + + + + + + + Is this file the one it claims to be? + + + + + + + + + + + + + + + Works out the checksum of a file, and compares it with one you were given - from a download page, a release note or + a colleague. + + + + + + + + + + + + + + + + + + + + + + + + + What it works on + + + + + + + + + + + + + + + + File + + + + + + + + + + + + + + + + + + + + + + + + + + Choose... + + + + + + + + + + no file was given - the tool has nothing to read without one. Name the file to work on + + + + + + + + + + Algorithm + + + + + + + + + + + + + + + default: sha256 + + + + + + + + + + + + + + + + Expected + + + + + + + + + + + + + + + not set + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Result + + + + + + + + + + + + + + + Nothing worked out yet. Choose what to work on and press Run. + + + + + + + + + + + + + + + + + + + + + + + Run + + + + + + + + + + + + + + + + + + + Donate + + + + + + + + + + + + + + + + diff --git a/internal/guard/testdata/screens/tools-result.png b/internal/guard/testdata/screens/tools-result.png new file mode 100644 index 00000000..eaebd53a Binary files /dev/null and b/internal/guard/testdata/screens/tools-result.png differ diff --git a/internal/guard/testdata/screens/tools-result.xml b/internal/guard/testdata/screens/tools-result.xml new file mode 100644 index 00000000..0ddc90f9 --- /dev/null +++ b/internal/guard/testdata/screens/tools-result.xml @@ -0,0 +1,382 @@ + + + + + + + + + + + Single batch + + + + + Presets + + + + + Several batches + + + + + Tools + + + + + Preferences + + + + + About + + + + + + + + + + + + + + + + Tools + + + + + + + Small things to do with files you already have. Every tool here is on the command line too, as tfg tool. + + + + + + + + + + + + + + + + + + + + + + The question + + + + + + + + + + + + + + Tool + + + + + + + + + + + + + Is this file the one it claims to be? + + + + + + + + + + + + + + + Works out the checksum of a file, and compares it with one you were given - from a download page, a release note or + a colleague. + + + + + + + + + + + + + + + + + + + + + + + + + What it works on + + + + + + + + + + + + + + + + File + + + + + + + + + + + + + + + testdata/checksum-sample.txt + + + + + + + + Choose... + + + + + + + + + + + Algorithm + + + + + + + + + + + + + + + default: sha256 + + + + + + + + + + + + + + + + Expected + + + + + + + + + + + + + + + ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Result + + + + + + + + + + + + + + + + + sha256 + + + + + + ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad + + + + + + + Copy + + + + + + + + + + Matches: the sha256 is ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad, as expected. + + + + + + + + + + + + + + + + + + + + + + + Run + + + + + + + + + + + + + + + + + + + Donate + + + + + + + + + + + + + + + + diff --git a/internal/guard/testdata/screens/tools.png b/internal/guard/testdata/screens/tools.png new file mode 100644 index 00000000..e85e8df9 Binary files /dev/null and b/internal/guard/testdata/screens/tools.png differ diff --git a/internal/guard/testdata/screens/tools.xml b/internal/guard/testdata/screens/tools.xml new file mode 100644 index 00000000..41ccc36e --- /dev/null +++ b/internal/guard/testdata/screens/tools.xml @@ -0,0 +1,360 @@ + + + + + + + + + + + Single batch + + + + + Presets + + + + + Several batches + + + + + Tools + + + + + Preferences + + + + + About + + + + + + + + + + + + + + + + Tools + + + + + + + Small things to do with files you already have. Every tool here is on the command line too, as tfg tool. + + + + + + + + + + + + + + + + + + + + + + The question + + + + + + + + + + + + + + Tool + + + + + + + + + + + + + Is this file the one it claims to be? + + + + + + + + + + + + + + + Works out the checksum of a file, and compares it with one you were given - from a download page, a release note or + a colleague. + + + + + + + + + + + + + + + + + + + + + + + + + What it works on + + + + + + + + + + + + + + + + File + + + + + + + + + + + + + + + + + + + + + + + + + + Choose... + + + + + + + + + + + Algorithm + + + + + + + + + + + + + + + default: sha256 + + + + + + + + + + + + + + + + Expected + + + + + + + + + + + + + + + not set + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Result + + + + + + + + + + + + + + + Nothing worked out yet. Choose what to work on and press Run. + + + + + + + + + + + + + + + + + + + + + + + Run + + + + + + + + + + + + + + + + + + + + + + + + Donate + + + + + + + + + + + + + + + + diff --git a/internal/guard/tools_test.go b/internal/guard/tools_test.go new file mode 100644 index 00000000..4b384c41 --- /dev/null +++ b/internal/guard/tools_test.go @@ -0,0 +1,329 @@ +package guard + +import ( + "bytes" + "context" + "encoding/json" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "testing" + "time" + + "github.com/donislawdev/TestingFilesGenerator/internal/cli" + "github.com/donislawdev/TestingFilesGenerator/internal/gui/text" + "github.com/donislawdev/TestingFilesGenerator/internal/gui/window" + "github.com/donislawdev/TestingFilesGenerator/internal/tool" + "github.com/donislawdev/TestingFilesGenerator/internal/tool/checksum" +) + +// The Tools tab and tfg tool - docs/NARZEDZIA-SUMY-2026-09-29.md. + +// knownChecksums are published answers: RFC 1321 for md5, FIPS 180-2 appendix +// examples for the SHA family ("abc"), and the check value every CRC-32/IEEE +// catalogue gives for "123456789". Written down rather than worked out, so a +// wrong table in the tool and a wrong table here cannot agree by sharing code. +var knownChecksums = []struct { + content, algorithm, sum string +}{ + {"abc", "md5", "900150983cd24fb0d6963f7d28e17f72"}, + {"abc", "sha1", "a9993e364706816aba3e25717850c26c9cd0d89d"}, + {"abc", "sha256", "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"}, + {"abc", "sha512", "ddaf35a193617abacc417349ae20413112e6fa4e89a97ea20a9eeee64b55d39a" + + "2192992a274fc1a836ba3c23a3feebbd454d4423643ce80e2a9ac94fa54ca49f"}, + {"123456789", "crc32", "cbf43926"}, + {"", "sha256", "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"}, + {"", "md5", "d41d8cd98f00b204e9800998ecf8427e"}, +} + +// publishedAnswer is the checksum knownChecksums gives for a content, so a +// guard that needs one reads it from the table rather than typing it again. +func publishedAnswer(t *testing.T, content, algorithm string) string { + t.Helper() + for _, k := range knownChecksums { + if k.content == content && k.algorithm == algorithm { + return k.sum + } + } + t.Fatalf("knownChecksums has no %s of %q", algorithm, content) + return "" +} + +// checksumOf runs the checksum tool the way both surfaces do, through Start. +func checksumOf(t *testing.T, path string, values map[string]string) (tool.Result, error) { + t.Helper() + d, err := tool.Get(checksum.ID) + if err != nil { + t.Fatal(err) + } + return d.Start(context.Background(), tool.Request{ + Inputs: map[string]string{checksum.InputFile: path}, Values: values, + }, nil) +} + +func writeTemp(t *testing.T, name, content string) string { + t.Helper() + path := filepath.Join(t.TempDir(), name) + if err := os.WriteFile(path, []byte(content), 0o644); err != nil { + t.Fatal(err) + } + return path +} + +// TestTheChecksumsAreTheKnownAnswers holds every algorithm to a published +// answer - a wrong algorithm behind a right name, or a checksum written out in +// the wrong byte order, turns this red. +func TestTheChecksumsAreTheKnownAnswers(t *testing.T) { + for _, k := range knownChecksums { + path := writeTemp(t, "known.bin", k.content) + r, err := checksumOf(t, path, map[string]string{checksum.SettingAlgorithm: k.algorithm}) + if err != nil { + t.Fatalf("%s of %q: %v", k.algorithm, k.content, err) + } + got := r.Data.(*checksum.Checksums).Checksums[k.algorithm] + if got != k.sum { + t.Errorf("%s of %q is %s and the published answer is %s", k.algorithm, k.content, got, k.sum) + } + } + // Every algorithm the tool offers has an answer above, so one added + // tomorrow is not offered unchecked. + for _, name := range checksum.Names() { + found := false + for _, k := range knownChecksums { + found = found || k.algorithm == name + } + if !found { + t.Errorf("the tool offers %s and no published answer holds it", name) + } + } +} + +// TestTheChecksumsAgreeWithTheSystemsOwnTools asks md5sum, sha1sum, sha256sum +// and sha512sum about the same file - the programs people check a download +// with, so a checksum from here that they would not reproduce is a defect +// whatever the published vectors say. +// +// A tool missing from this machine is said, not skipped in silence. On a Linux +// runner of the CI all four are part of the system, so there a missing one is +// a failure: a guard that checked nothing there would look like one that +// checked everything. +func TestTheChecksumsAgreeWithTheSystemsOwnTools(t *testing.T) { + content := strings.Repeat("the quick brown fox jumps over the lazy dog\n", 5000) + path := writeTemp(t, "sample.txt", content) + r, err := checksumOf(t, path, map[string]string{checksum.SettingAlgorithm: "all"}) + if err != nil { + t.Fatal(err) + } + ours := r.Data.(*checksum.Checksums).Checksums + + checked := 0 + for _, name := range []string{"md5", "sha1", "sha256", "sha512"} { + program, err := exec.LookPath(name + "sum") + if err != nil { + t.Logf("SKIPPED: %ssum is not installed here", name) + continue + } + out, err := exec.Command(program, path).Output() + if err != nil { + t.Fatalf("%ssum: %v", name, err) + } + theirs, _, _ := strings.Cut(strings.TrimSpace(string(out)), " ") + theirs = strings.TrimPrefix(theirs, `\`) + if theirs != ours[name] { + t.Errorf("%s: this tool says %s and %ssum says %s", name, ours[name], name, theirs) + } + checked++ + } + if checked == 0 && runtime.GOOS == "linux" && os.Getenv("CI") != "" { + t.Fatal("no md5sum, sha1sum, sha256sum or sha512sum on a Linux runner, so nothing was compared") + } + t.Logf("%d algorithm(s) compared with the system's own program", checked) +} + +// TestTheWindowSaysAVerdictAsTheCommandLineDoes holds the English of the two +// wordings of one verdict to one sentence. The window words it from its +// catalogue so it can say it in another language, and the command line from +// tool.Verdict.Said - two compositions, which drift unless compared (D1). +func TestTheWindowSaysAVerdictAsTheCommandLineDoes(t *testing.T) { + match := tool.Verdict{Outcome: tool.Match, About: "sha256", Got: "abc123", Wanted: "abc123"} + if got, want := text.ToolMatches(match.About, match.Got), match.Said(); got != want { + t.Errorf("a match reads %q in the window and %q on the command line", got, want) + } + miss := tool.Verdict{Outcome: tool.Mismatch, About: "md5", Got: "aa", Wanted: "bb"} + if got, want := text.ToolDoesNotMatch(miss.About, miss.Got, miss.Wanted), miss.Said(); got != want { + t.Errorf("a mismatch reads %q in the window and %q on the command line", got, want) + } + if said := (tool.Verdict{Outcome: tool.Unasked}).Said(); said != "" { + t.Errorf("nothing was compared and the command line says %q", said) + } +} + +// TestTheToolsScreenOffersEveryToolWithEveryBox walks the registry, not a list: +// every tool is in the menu, and choosing it draws a box named for everything +// it works on and every setting it takes. A tool registered tomorrow is held +// on the day it arrives. +func TestTheToolsScreenOffersEveryToolWithEveryBox(t *testing.T) { + host := newFakeHost(t) + window.Open(host) + screen := selectTab(t, host.content, text.TabTools()) + menu := chooserUnder(t, screen, text.FieldTool()) + all := tool.All() + if len(all) == 0 { + t.Fatal("the registry holds no tool, so this guard would pass against an empty screen") + } + for _, d := range all { + question := text.ToolQuestion(d.ID, d.Question) + menu.SetSelected(question) + if menu.Selected != question { + t.Errorf("the menu does not offer %s (%q)", d.ID, question) + continue + } + words := allText(screen) + for _, in := range d.Inputs { + if !strings.Contains(words, text.SettingLabel(in.Name)) { + t.Errorf("%s works on a %s and the screen has no box named %q", d.ID, in.Name, text.SettingLabel(in.Name)) + } + } + for _, p := range d.Settings { + if !strings.Contains(words, text.SettingLabel(p.Name)) { + t.Errorf("%s takes %s and the screen has no box named %q", d.ID, p.Name, text.SettingLabel(p.Name)) + } + } + } +} + +// TestTheWindowRunsAToolAndSaysWhatTheCommandLineSays runs the checksum tool +// from the screen and from tfg tool on one file, and compares the answers - the +// run from the window through the boxes a person fills and the button a person +// presses. +func TestTheWindowRunsAToolAndSaysWhatTheCommandLineSays(t *testing.T) { + path := writeTemp(t, "both.bin", strings.Repeat("x", 100000)) + var out, errOut bytes.Buffer + if code := cli.Run(context.Background(), []string{"tool", checksum.ID, path, "--json"}, &out, &errOut); code != cli.ExitOK { + t.Fatalf("tfg tool checksum ended with %d: %s", code, errOut.String()) + } + var said checksum.Checksums + if err := json.Unmarshal(out.Bytes(), &said); err != nil { + t.Fatal(err) + } + want := said.Checksums["sha256"] + + host := newFakeHost(t) + window.Open(host) + screen := selectTab(t, host.content, text.TabTools()) + fillField(t, screen, text.SettingLabel(checksum.InputFile), path) + pressNamed(t, screen, text.ButtonRunTool()) + host.waitForWork() + if words := allText(screen); !strings.Contains(words, want) { + t.Errorf("the command line says the sha256 is %s and the screen does not show it. It shows:\n%s", want, words) + } +} + +// TestToolsEndWithTheCodeOfWhatHappened holds the exit codes of tfg tool to +// the frozen table: a mismatch is VERIFY, a mistake in the request USAGE, a +// path that cannot be read IO. +func TestToolsEndWithTheCodeOfWhatHappened(t *testing.T) { + file := writeTemp(t, "abc.txt", "abc") + sha := "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad" + wrong := strings.Repeat("0", 64) + for _, c := range []struct { + name string + args []string + want int + }{ + {"the checksum given", []string{file, "--expected", sha}, cli.ExitOK}, + {"the checksum given in capitals", []string{file, "--expected", strings.ToUpper(sha)}, cli.ExitOK}, + {"a different checksum", []string{file, "--expected", wrong}, cli.ExitVerify}, + {"an algorithm spelled otherwise", []string{file, "--algorithm", "SHA256"}, cli.ExitUsage}, + {"a checksum no algorithm has", []string{file, "--expected", "abcd"}, cli.ExitUsage}, + {"no file", nil, cli.ExitUsage}, + {"a directory", []string{filepath.Dir(file)}, cli.ExitUsage}, + {"a file that is not there", []string{file + ".missing"}, cli.ExitIO}, + {"two files", []string{file, file}, cli.ExitUsage}, + } { + var out, errOut bytes.Buffer + code := cli.Run(context.Background(), append([]string{"tool", checksum.ID}, c.args...), &out, &errOut) + if code != c.want { + t.Errorf("%s: ended with %d and the table says %d. It said: %s%s", c.name, code, c.want, out.String(), errOut.String()) + } + if code != cli.ExitOK && out.Len() > 0 { + t.Errorf("%s: failed and still wrote to standard output: %s", c.name, out.String()) + } + } +} + +// TestALoneDashIsAFileNameAndNotAHang holds "tfg tool checksum -" to what +// "tfg verify -" does: a path, read like any other, and an answer. Until +// 2026-09-30 the flags were read again and again around a "-" nothing ever +// took, and the command never returned (a review of #157, measured before the +// fix: killed by timeout, code 124). The failure being guarded is a hang, so +// every run has a deadline and says so. +func TestALoneDashIsAFileNameAndNotAHang(t *testing.T) { + t.Chdir(t.TempDir()) + runs := func(args ...string) (int, string) { + t.Helper() + type ending struct { + code int + out string + } + ended := make(chan ending, 1) + go func() { + var out, errOut bytes.Buffer + code := cli.Run(context.Background(), append([]string{"tool", checksum.ID}, args...), &out, &errOut) + ended <- ending{code, out.String()} + }() + select { + case e := <-ended: + return e.code, e.out + case <-time.After(10 * time.Second): + t.Fatalf("tfg tool checksum %s has not returned after ten seconds", strings.Join(args, " ")) + return 0, "" + } + } + + if code, _ := runs("-"); code != cli.ExitIO { + t.Errorf("nothing is called - here and the tool ended with %d, not %d as for any path that is not there", + code, cli.ExitIO) + } + if err := os.WriteFile("-", []byte("abc"), 0o644); err != nil { + t.Fatal(err) + } + want := publishedAnswer(t, "abc", "md5") + for _, args := range [][]string{{"-", "--algorithm", "md5"}, {"--algorithm", "md5", "-"}} { + code, out := runs(args...) + if code != cli.ExitOK || !strings.Contains(out, want) { + t.Errorf("a file is called - and tfg tool checksum %s ended with %d, saying:\n%s", + strings.Join(args, " "), code, out) + } + } +} + +// TestTheToolsScreenTakesAChosenFileAndCopiesTheChecksum asks the two things +// only the Tools tab asks of a window: the file picker, whose answer has to +// land in the box, and the clipboard, which has to get the checksum the row +// shows. The stand in window recorded both from the day they arrived and +// nothing read either until 2026-09-30. +func TestTheToolsScreenTakesAChosenFileAndCopiesTheChecksum(t *testing.T) { + path := writeTemp(t, "picked.txt", "abc") + host := newFakeHost(t) + host.pickedFile = path + window.Open(host) + screen := selectTab(t, host.content, text.TabTools()) + + pressNamed(t, screen, text.ButtonChoose()) + if host.askedFile == 0 { + t.Fatal("the browse button of the Tools tab asked nobody for a file") + } + if got := entryUnder(t, screen, text.SettingLabel(checksum.InputFile)).Text; got != path { + t.Fatalf("%s was chosen and the box holds %q", path, got) + } + + pressNamed(t, screen, text.ButtonRunTool()) + host.waitForWork() + pressNamed(t, screen, text.ButtonCopy()) + if want := publishedAnswer(t, "abc", "sha256"); host.copied != want { + t.Errorf("Copy put %q on the clipboard and the sha256 of the file is %s", host.copied, want) + } +} diff --git a/internal/guard/tools_unix_test.go b/internal/guard/tools_unix_test.go new file mode 100644 index 00000000..0b10e1c3 --- /dev/null +++ b/internal/guard/tools_unix_test.go @@ -0,0 +1,50 @@ +//go:build !windows + +package guard + +import ( + "context" + "errors" + "path/filepath" + "syscall" + "testing" + "time" + + "github.com/donislawdev/TestingFilesGenerator/internal/tool" + "github.com/donislawdev/TestingFilesGenerator/internal/tool/checksum" +) + +// TestAToolNeverWaitsOnAPipe names a pipe as the file to work a checksum out of. +// Opening one for reading waits until something writes to it, which may be +// never - so the open must not wait, and what was opened has to be refused +// before a byte of it is read. An open that waits hangs here until the +// deadline and says so, and one that reads the pipe hands back the checksum +// of nothing instead of the refusal. +// +// Since 2026-09-30 this is asked of the open file rather than of the name +// (checksum.openRegular), which is what closed the moment between the two +// looks that a review of #157 found - so this one guard holds both halves. +func TestAToolNeverWaitsOnAPipe(t *testing.T) { + pipe := filepath.Join(t.TempDir(), "pipe") + if err := syscall.Mkfifo(pipe, 0o600); err != nil { + t.Fatalf("making a pipe to name: %v", err) + } + d, err := tool.Get(checksum.ID) + if err != nil { + t.Fatal(err) + } + answered := make(chan error, 1) + go func() { + _, err := d.Start(context.Background(), tool.Request{Inputs: map[string]string{checksum.InputFile: pipe}}, nil) + answered <- err + }() + select { + case err := <-answered: + var notAFile *checksum.NotAFileError + if !errors.As(err, ¬AFile) { + t.Errorf("a pipe was named and the answer was %v, not the refusal saying it is not a file", err) + } + case <-time.After(10 * time.Second): + t.Fatal("a pipe was named and the tool is still waiting on it after ten seconds - it opened the pipe") + } +} diff --git a/internal/guard/window_test.go b/internal/guard/window_test.go index 7275b12d..ce2a69a3 100644 --- a/internal/guard/window_test.go +++ b/internal/guard/window_test.go @@ -72,6 +72,13 @@ type fakeHost struct { closed int picked string asked int + // pickedFile is what ChooseFile answers, askedFile how often it was asked, + // and copied the last text Copy was given - the things only the Tools tab + // asks of a window. Counted apart from asked, so a guard about the + // directory picker cannot be satisfied by the file picker. + pickedFile string + askedFile int + copied string opened string openedCount int @@ -465,6 +472,13 @@ func (h *fakeHost) ChooseDirectory(chosen func(string)) { chosen(h.picked) } +func (h *fakeHost) ChooseFile(chosen func(string)) { + h.askedFile++ + chosen(h.pickedFile) +} + +func (h *fakeHost) Copy(text string) { h.copied = text } + // A tree that renders as one flat colour passes every structural check and // shows nothing. That defect is not hypothetical here: SVG at exactly its // minimum size rendered as a single colour and passed every other guard in diff --git a/internal/guard/writeescape_test.go b/internal/guard/writeescape_test.go index ed1f25e5..f984e0c2 100644 --- a/internal/guard/writeescape_test.go +++ b/internal/guard/writeescape_test.go @@ -97,7 +97,7 @@ func TestEveryFileThisToolWritesIsCreatedThroughOneClaim(t *testing.T) { return true } pkg, ok := sel.X.(*ast.Ident) - if !ok || pkg.Name != "os" || !creators[sel.Sel.Name] { + if !ok || pkg.Name != "os" || !creators[sel.Sel.Name] || opensOnlyToRead(call) { return true } if _, granted := allowed[rel]; granted { @@ -131,6 +131,42 @@ func TestEveryFileThisToolWritesIsCreatedThroughOneClaim(t *testing.T) { } } +// readingFlags are the flags of an open that only reads - os.Open with +// something os.Open cannot ask for. O_NONBLOCK is why this exists: the +// checksum tool opens without waiting on a pipe (checksum.openRegular), which +// takes os.OpenFile, and it creates nothing and writes nothing. +var readingFlags = map[string]bool{ + "O_RDONLY": true, "O_NONBLOCK": true, "O_CLOEXEC": true, "O_NOFOLLOW": true, "O_NOCTTY": true, +} + +// opensOnlyToRead says whether a call is os.OpenFile with flags that read and +// nothing else, spelled out where the call is. Anything else - a flag that +// writes or creates, or flags held in a variable this cannot see into - is +// taken as a create, so what cannot be read here is refused rather than +// waved through. A read-only open is a question of reading through a link, +// which the comment on creators leaves to core.Boundary. +func opensOnlyToRead(call *ast.CallExpr) bool { + sel, ok := call.Fun.(*ast.SelectorExpr) + if !ok || sel.Sel.Name != "OpenFile" || len(call.Args) < 2 { + return false + } + only := true + ast.Inspect(call.Args[1], func(n ast.Node) bool { + switch v := n.(type) { + case *ast.BinaryExpr: + only = only && v.Op == token.OR + case nil, *ast.ParenExpr, *ast.SelectorExpr: + // nil is Inspect leaving a node, not a node. + case *ast.Ident: + only = only && (readingFlags[v.Name] || v.Name == "os" || v.Name == "syscall") + default: + only = false + } + return only + }) + return only +} + // core.CreateNew creates only when the name is free, and what it refuses covers // every way a name can be held. // diff --git a/internal/gui/parts/property.go b/internal/gui/parts/property.go index 18be1f3b..f8d67eb0 100644 --- a/internal/gui/parts/property.go +++ b/internal/gui/parts/property.go @@ -267,8 +267,9 @@ func DeclaredFields(owner text.Owner, declared []format.Property, into *Fields, // The button is what makes that safe rather than a loss: this is a tool // whose window and whose recipe file are two ways into one engine, so // somebody who finds a setting here has to be able to write it down. + // For a tool that is a flag rather than a recipe key - see WrittenAs. objects = append(objects, into.Add(p.Name, text.SettingLabel(p.Name), PropertyDetail(owner, p), - tips.Say(text.SettingKey(p.Name)), ShapedFor(p, f.Control))) + tips.Say(text.WrittenAs(owner, p.Name)), ShapedFor(p, f.Control))) } return fields, objects } diff --git a/internal/gui/run_cgo.go b/internal/gui/run_cgo.go index 424004b4..138d5175 100644 --- a/internal/gui/run_cgo.go +++ b/internal/gui/run_cgo.go @@ -212,6 +212,43 @@ func (d desktop) ChooseDirectory(chosen func(string)) { }, d.Window) } +// ChooseFile is ChooseDirectory for one file. The toolkit's picker hands back +// the file already opened for reading, and a tool opens it again itself - it +// has to, to see whether it is a file at all before reading - so the handle is +// closed here and only the path goes on. +// +// Closed whenever one is handed over, error or not: the toolkit's rule is that +// a reader which is not nil is the callback's to close (fyne v2.8.1, +// dialog/file.go:871). Its own file reader hands back a wrapper with nothing +// open inside when the open failed (internal/repository/file.go:337), so no +// handle leaked before 2026-09-30 - the order follows the rule rather than +// that detail, which a review of #157 asked for. +func (d desktop) ChooseFile(chosen func(string)) { + dialog.ShowFileOpen(func(file fyne.URIReadCloser, err error) { + chosen(pickedPath(file, err)) + }, d.Window) +} + +// pickedPath is the path of what the picker handed over, or nothing, with the +// reader closed either way. +func pickedPath(file fyne.URIReadCloser, err error) string { + if file == nil { + return "" + } + _ = file.Close() + if err != nil { + return "" + } + return file.URI().Path() +} + +// Copy puts text on the clipboard of the application, which is where the +// toolkit keeps it since the window's own was deprecated (fyne v2.8.1, +// window.go:105). +func (d desktop) Copy(text string) { + fyne.CurrentApp().Clipboard().SetContent(text) +} + // OpenLink hands an address to the desktop's own browser. // // The program makes no request. It parses the address and passes it to the diff --git a/internal/gui/text/locale/en.json b/internal/gui/text/locale/en.json index d934fc56..698d8b70 100644 --- a/internal/gui/text/locale/en.json +++ b/internal/gui/text/locale/en.json @@ -63,6 +63,10 @@ "description": "The words on a button.", "other": "Choose..." }, + "ButtonCopy": { + "description": "The words on a button.", + "other": "Copy" + }, "ButtonDonate": { "description": "The words on a button.", "other": "Donate" @@ -107,6 +111,10 @@ "description": "The words on a button.", "other": "Restart now" }, + "ButtonRunTool": { + "description": "The words on a button.", + "other": "Run" + }, "ChoiceSameAsSystem": { "description": "An entry in a list somebody chooses from. Carries one value, {{.Language}}, which has to stay spelled exactly that way.", "other": "Same as the system ({{.Language}})" @@ -203,6 +211,10 @@ "description": "The longer explanation behind the button beside a field name.", "other": "It reaches the manifest and the file names, so a test can tell these files from the rest of the run. The seeds are derived from it, so changing it changes the bytes." }, + "DetailTool": { + "description": "The longer explanation behind the button beside a field name.", + "other": "What the tool works on and its settings change with the tool chosen." + }, "DirectoryWithFreeSpace": { "description": "Shown in the window. Carries these values, each of which has to stay spelled exactly that way: {{.Directory}}, {{.Free}}.", "other": "{{.Directory}} ({{.Free}} free)" @@ -291,6 +303,10 @@ "description": "The name above a box somebody fills in.", "other": "Batch name" }, + "FieldTool": { + "description": "The name above a box somebody fills in.", + "other": "Tool" + }, "Files": { "description": "Shown in the window. One form per number, and a language may need more than the two English has - add them beside these. Carries one value, {{.Count}}, which has to stay spelled exactly that way.", "one": "1 file", @@ -369,6 +385,10 @@ "description": "The line under a field name, saying what the field does.", "other": "A short name for this batch." }, + "HintTool": { + "description": "The line under a field name, saying what the field does.", + "other": "What you want to find out." + }, "HowToUseChoose": { "description": "Shown in the window.", "other": "Choose a preset, or fill in one batch on the first screen." @@ -613,6 +633,18 @@ "description": "The heading over a group of fields.", "other": "Support" }, + "SectionTool": { + "description": "The heading over a group of fields.", + "other": "The question" + }, + "SectionToolInput": { + "description": "The heading over a group of fields.", + "other": "What it works on" + }, + "SectionToolResult": { + "description": "The heading over a group of fields.", + "other": "Result" + }, "SettingKey": { "description": "Shown in the window. Carries one value, {{.Key}}, which has to stay spelled exactly that way.", "other": "Written as {{.Key}} in a recipe." @@ -665,6 +697,10 @@ "description": "The sentence under the heading at the top of a screen, saying what the screen is for.", "other": "Batches of different formats and sizes, generated together in one run." }, + "SubtitleTools": { + "description": "The sentence under the heading at the top of a screen, saying what the screen is for.", + "other": "Small things to do with files you already have. Every tool here is on the command line too, as tfg tool." + }, "TabAbout": { "description": "The name of a tab along the top of the window.", "other": "About" @@ -685,6 +721,10 @@ "description": "The name of a tab along the top of the window.", "other": "Several batches" }, + "TabTools": { + "description": "The name of a tab along the top of the window.", + "other": "Tools" + }, "TimeLeft": { "description": "Shown in the window. Carries one value, {{.Roughly}}, which has to stay spelled exactly that way.", "other": "{{.Roughly}} left" @@ -693,6 +733,38 @@ "description": "Shown in the window. Carries these values, each of which has to stay spelled exactly that way: {{.Count}}, {{.Reason}}.", "other": "this run asks for {{.Count}} files - {{.Reason}}" }, + "ToolCopied": { + "description": "Shown in the window. Carries one value, {{.What}}, which has to stay spelled exactly that way.", + "other": "The {{.What}} is on the clipboard." + }, + "ToolDoesNotMatch": { + "description": "Shown in the window. Carries these values, each of which has to stay spelled exactly that way: {{.About}}, {{.Got}}, {{.Wanted}}.", + "other": "Does not match: the {{.About}} is {{.Got}} and {{.Wanted}} was expected." + }, + "ToolFlag": { + "description": "Shown in the window. Carries one value, {{.Key}}, which has to stay spelled exactly that way.", + "other": "Written as --{{.Key}} after tfg tool on the command line." + }, + "ToolInputWrittenAs": { + "description": "Shown in the window. Carries one value, {{.Tool}}, which has to stay spelled exactly that way.", + "other": "Written as the path after tfg tool {{.Tool}} on the command line." + }, + "ToolMatches": { + "description": "Shown in the window. Carries these values, each of which has to stay spelled exactly that way: {{.About}}, {{.Got}}.", + "other": "Matches: the {{.About}} is {{.Got}}, as expected." + }, + "ToolNothingYet": { + "description": "Shown in the window.", + "other": "Nothing worked out yet. Choose what to work on and press Run." + }, + "ToolReading": { + "description": "Shown in the window. Carries these values, each of which has to stay spelled exactly that way: {{.Done}}, {{.Total}}.", + "other": "Read {{.Done}} of {{.Total}}." + }, + "ToolStopped": { + "description": "Shown in the window.", + "other": "Stopped before it finished. A tool only reads, so nothing was changed." + }, "UseSmallestSize": { "description": "Shown in the window. Carries one value, {{.Size}}, which has to stay spelled exactly that way.", "other": "Use the smallest size, {{.Size}}" diff --git a/internal/gui/text/locale/pl.json b/internal/gui/text/locale/pl.json index cf829e59..601fda94 100644 --- a/internal/gui/text/locale/pl.json +++ b/internal/gui/text/locale/pl.json @@ -15,6 +15,7 @@ "ButtonAddContents": { "other": "Dodaj pliki do środka" }, "ButtonCancel": { "other": "Anuluj" }, "ButtonChoose": { "other": "Wybierz..." }, + "ButtonCopy": { "other": "Kopiuj" }, "ButtonDonate": { "other": "Wesprzyj" }, "ButtonDuplicateBatch": { "other": "Powiel" }, "ButtonForget": { "other": "Zapomnij" }, @@ -26,6 +27,7 @@ "ButtonRemoveBatch": { "other": "Usuń" }, "ButtonRemoveContents": { "other": "Usuń" }, "ButtonRestart": { "other": "Uruchom ponownie" }, + "ButtonRunTool": { "other": "Uruchom" }, "ChoiceSameAsSystem": { "other": "Jak w systemie ({{.Language}})" }, "ContentsHeading": { "other": "Pliki w każdym archiwum" }, "DamageNone": { "other": "brak" }, @@ -50,6 +52,7 @@ "DetailSizeRange": { "other": "Dwa rozmiary z łącznikiem, na przykład 1kb-8kb. Każdy plik dostaje własny rozmiar wyprowadzony z ziarna, więc przebieg się powtarza." }, "DetailSizeWay": { "other": "Jeden rozmiar daje każdemu plikowi ten sam rozmiar. Zakres losuje inny rozmiar dla każdego pliku. Wokół limitu tworzy trzy pliki: bajt poniżej limitu, dokładnie na nim i bajt powyżej." }, "DetailTargetID": { "other": "Trafia do manifestu i do nazw plików, więc test odróżni te pliki od reszty przebiegu. Ziarna są z niej wyprowadzane, więc jej zmiana zmienia bajty." }, + "DetailTool": { "other": "To, na czym narzędzie pracuje, i jego ustawienia zmieniają się razem z wybranym narzędziem." }, "DirectoryWithFreeSpace": { "other": "{{.Directory}} (wolne {{.Free}})" }, "FieldBasePreset": { "other": "Preset bazowy" }, "FieldBoundary": { "other": "Testowany limit" }, @@ -72,6 +75,7 @@ "FieldSizeRange": { "other": "Zakres rozmiarów" }, "FieldSizeWay": { "other": "Jak podany jest rozmiar" }, "FieldTargetID": { "other": "Nazwa partii" }, + "FieldTool": { "other": "Narzędzie" }, "Files": { "one": "1 plik", "few": "{{.Count}} pliki", "many": "{{.Count}} plików", "other": "{{.Count}} pliku" }, "FormatCount": { "one": "1 format", "few": "{{.Count}} formaty", "many": "{{.Count}} formatów", "other": "{{.Count}} formatu" }, "HintBasePreset": { "other": "Jego pliki idą pierwsze." }, @@ -91,6 +95,7 @@ "HintSizeExact": { "other": "Jeden rozmiar dla każdego pliku." }, "HintSizeRange": { "other": "Inny rozmiar dla każdego pliku." }, "HintTargetID": { "other": "Krótka nazwa tej partii." }, + "HintTool": { "other": "Czego chcesz się dowiedzieć." }, "HowToUseChoose": { "other": "Wybierz preset albo wypełnij jedną partię na pierwszym ekranie." }, "HowToUsePress": { "other": "Naciśnij Generuj. Pliki i manifest trafią do katalogu wyjściowego." }, "HowToUseRead": { "other": "Skieruj swój test na manifest. Dla każdego pliku mówi, co system pod testem powinien z nim zrobić - przyjąć, odrzucić albo oczyścić - albo zapisuje wynik jako unspecified, gdy właściwa odpowiedź należy do polityki samej aplikacji." }, @@ -152,6 +157,9 @@ "SectionRemembered": { "other": "Co pamięta to okno" }, "SectionSettings": { "other": "Ustawienia" }, "SectionSupport": { "other": "Wsparcie" }, + "SectionTool": { "other": "Pytanie" }, + "SectionToolInput": { "other": "Na czym pracuje" }, + "SectionToolResult": { "other": "Wynik" }, "SettingKey": { "other": "W przepisie zapisywane jako {{.Key}}." }, "SettingsFor": { "other": "Ustawienia dla {{.Format}}" }, "SizeAndBytes": { "other": "{{.Human}} ({{.Exact}})" }, @@ -165,13 +173,23 @@ "SubtitlePreferences": { "other": "Jak mówi to okno i co zachowuje między uruchomieniami. Nic tutaj nie zmienia plików, które tworzy." }, "SubtitlePreset": { "other": "Gotowe zestawy plików, każdy zbudowany, by odpowiedzieć na jedno pytanie o system pod testem." }, "SubtitleRecipe": { "other": "Partie różnych formatów i rozmiarów, generowane razem w jednym przebiegu." }, + "SubtitleTools": { "other": "Drobne czynności na plikach, które już masz. Każde narzędzie z tej zakładki jest też w linii poleceń, jako tfg tool." }, "TabAbout": { "other": "O programie" }, "TabOneTarget": { "other": "Jedna partia" }, "TabPreferences": { "other": "Preferencje" }, "TabPresets": { "other": "Presety" }, "TabRecipe": { "other": "Kilka partii" }, + "TabTools": { "other": "Narzędzia" }, "TimeLeft": { "other": "zostało {{.Roughly}}" }, "TooManyFiles": { "other": "liczba plików w tym przebiegu to {{.Count}} - {{.Reason}}" }, + "ToolCopied": { "other": "Skopiowano do schowka: {{.What}}." }, + "ToolDoesNotMatch": { "other": "Nie zgadza się: {{.About}} to {{.Got}}, a oczekiwano {{.Wanted}}." }, + "ToolFlag": { "other": "W linii poleceń zapisuje się jako --{{.Key}} po tfg tool." }, + "ToolInputWrittenAs": { "other": "W linii poleceń zapisuje się jako ścieżka po tfg tool {{.Tool}}." }, + "ToolMatches": { "other": "Zgadza się: {{.About}} to {{.Got}}, tak jak oczekiwano." }, + "ToolNothingYet": { "other": "Jeszcze nic nie policzono. Wybierz, na czym narzędzie ma pracować, i naciśnij Uruchom." }, + "ToolReading": { "other": "Przeczytano {{.Done}} z {{.Total}}." }, + "ToolStopped": { "other": "Zatrzymano przed końcem. Narzędzie tylko czyta, więc nic nie zostało zmienione." }, "UseSmallestSize": { "other": "Użyj najmniejszego rozmiaru, {{.Size}}" }, "WillGoTo": { "other": "zapis do {{.Directory}}" }, "WindowRefused": { "other": "Nie udało się otworzyć okna. Rysuje ono przez OpenGL 2.1, a biblioteka graficzna nie zdołała uzyskać go od sterownika na tym komputerze. Komunikat biblioteki: {{.Cause}}. Wszystko, co robi okno, jest też dostępne w linii komend - uruchom „tfg --help” - i ona nie potrzebuje sterownika grafiki. Żeby mieć okno, użyj sterownika grafiki obsługującego OpenGL 2.1." }, diff --git a/internal/gui/text/locale/registry/en.json b/internal/gui/text/locale/registry/en.json index d5c899bf..bd33f7f2 100644 --- a/internal/gui/text/locale/registry/en.json +++ b/internal/gui/text/locale/registry/en.json @@ -609,11 +609,26 @@ "hash": "sha256-be929d76067d", "other": "The size limit your upload form declares. This set takes one step either side of it - for a file at every distance, run the size-boundaries preset." }, + "Detail.tool/checksum.algorithm": { + "description": "The sentence under the algorithm setting of the checksum tool, after what it takes.", + "hash": "sha256-b2c6c5ddeaa1", + "other": "Which checksums to work out: md5, sha1, sha256, sha512 or crc32. Only sha256 and sha512 still show that nobody changed the file on purpose. crc32 is the one ZIP and PNG use, not the one cksum prints." + }, + "Detail.tool/checksum.expected": { + "description": "The sentence under the expected setting of the checksum tool, after what it takes.", + "hash": "sha256-d2a5105f8783", + "other": "The checksum the file should have. The algorithm is told from its length, and worked out even when it is not chosen above." + }, "Group.format/pdf.Document properties": { "description": "The heading over a block of settings of the pdf format.", "hash": "sha256-7115a778ab47", "other": "Document properties" }, + "Input.checksum.file": { + "description": "The sentence under the file box of the checksum tool.", + "hash": "sha256-a87fb4cce79e", + "other": "The file to work out the checksums of. It is only read." + }, "Joint.format/avif.width.height": { "description": "Why width times height of the avif format have a ceiling, at the end of the note under the two, after the word because.", "hash": "sha256-269a6c476761", @@ -644,6 +659,11 @@ "hash": "sha256-e1ef283f3c5d", "other": "the sheet is built in memory before it is packaged" }, + "Label.algorithm": { + "description": "The name beside the box of a setting a recipe writes as algorithm.", + "hash": "sha256-d704d8af25be", + "other": "Algorithm" + }, "Label.allow": { "description": "The name beside the box of a setting a recipe writes as allow.", "hash": "sha256-e213c161d5ce", @@ -764,11 +784,21 @@ "hash": "sha256-1190635edb6f", "other": "Entry size" }, + "Label.expected": { + "description": "The name beside the box of a setting a recipe writes as expected.", + "hash": "sha256-ca99b7f1b14e", + "other": "Expected" + }, "Label.far-over": { "description": "The name beside the box of a setting a recipe writes as far-over.", "hash": "sha256-874367c30d4e", "other": "Far-over" }, + "Label.file": { + "description": "The name beside the box of what the checksum tool works on.", + "hash": "sha256-50009ce1da4d", + "other": "File" + }, "Label.format": { "description": "The name beside the box of a setting a recipe writes as format.", "hash": "sha256-2f343666aaa8", @@ -984,11 +1014,21 @@ "hash": "sha256-950469ca4b3a", "other": "Does my upload form take what it should and turn the rest away?" }, + "Shape.a checksum in hexadecimal": { + "description": "What the text in a box has to look like, in the sentence under it.", + "hash": "sha256-62cb052ebc94", + "other": "a checksum in hexadecimal" + }, "Shape.a date such as 2024-02-29 or 2024-02-29T13:45:00+02:00, or none": { "description": "What the text in a box has to look like, in the sentence under it.", "hash": "sha256-a40bd1decdc9", "other": "a date such as 2024-02-29 or 2024-02-29T13:45:00+02:00, or none" }, + "Shape.algorithm names separated by commas, or all": { + "description": "What the text in a box has to look like, in the sentence under it.", + "hash": "sha256-bba80a8dec88", + "other": "algorithm names separated by commas, or all" + }, "Shape.any text": { "description": "What the text in a box has to look like, in the sentence under it.", "hash": "sha256-06c83efcd22e", @@ -1024,6 +1064,16 @@ "hash": "sha256-eb266034b6a8", "other": "the password, in plain text" }, + "Tool.checksum.Detail": { + "description": "One sentence under the question of the checksum tool, saying what it does.", + "hash": "sha256-f3e1a6e1c983", + "other": "Works out the checksum of a file, and compares it with one you were given - from a download page, a release note or a colleague." + }, + "Tool.checksum.Question": { + "description": "The question the checksum tool answers - its name in the list of tools and the title over it.", + "hash": "sha256-f7c87f2771e3", + "other": "Is this file the one it claims to be?" + }, "Unit.bytes": { "description": "What a number counts, after it, as in: from 1 to 20000 bytes. Written the way the language puts a noun after a number.", "hash": "sha256-277089d91c0b", diff --git a/internal/gui/text/locale/registry/pl.json b/internal/gui/text/locale/registry/pl.json index 2bcf41f4..78608bbd 100644 --- a/internal/gui/text/locale/registry/pl.json +++ b/internal/gui/text/locale/registry/pl.json @@ -121,13 +121,17 @@ "Detail.preset/upload-validation.deny": { "hash": "sha256-f9e5c633d218", "other": "Jakie rozszerzenia twój formularz ma odrzucać. Rozszerzenie, dla którego ta wersja nie ma formatu, i tak dostaje plik o tej nazwie, ze zwykłym tekstem w środku." }, "Detail.preset/upload-validation.far-over": { "hash": "sha256-6a0093c6d7a0", "other": "Jak daleko ponad limit sięga jeden duży plik. Wyłącz tam, gdzie zapis kilkukrotności limitu nie jest wart miejsca na dysku." }, "Detail.preset/upload-validation.limit": { "hash": "sha256-be929d76067d", "other": "Limit rozmiaru, który deklaruje twój formularz przesyłania. Ten zestaw robi jeden krok po obu stronach - żeby dostać plik w każdej odległości, uruchom preset size-boundaries." }, + "Detail.tool/checksum.algorithm": { "hash": "sha256-b2c6c5ddeaa1", "other": "Które sumy kontrolne policzyć: md5, sha1, sha256, sha512 albo crc32. Tylko sha256 i sha512 nadal pokazują, że nikt celowo nie zmienił pliku. crc32 to ta, której używają ZIP i PNG, a nie ta, którą wypisuje cksum." }, + "Detail.tool/checksum.expected": { "hash": "sha256-d2a5105f8783", "other": "Suma kontrolna, jaką plik powinien mieć. Algorytm rozpoznaje się po jej długości i liczy go, nawet jeśli nie wybrano go wyżej." }, "Group.format/pdf.Document properties": { "hash": "sha256-7115a778ab47", "other": "Właściwości dokumentu" }, + "Input.checksum.file": { "hash": "sha256-a87fb4cce79e", "other": "Plik, którego sumy kontrolne mają zostać policzone. Jest tylko czytany." }, "Joint.format/avif.width.height": { "hash": "sha256-269a6c476761", "other": "koder trzyma cały obraz w pamięci podczas pracy" }, "Joint.format/gif.width.height": { "hash": "sha256-0bed7aa664d8", "other": "obraz jest trzymany w pamięci podczas kodowania" }, "Joint.format/jpg.width.height": { "hash": "sha256-0bed7aa664d8", "other": "obraz jest trzymany w pamięci podczas kodowania" }, "Joint.format/jxl.width.height": { "hash": "sha256-269a6c476761", "other": "koder trzyma cały obraz w pamięci podczas pracy" }, "Joint.format/png.width.height": { "hash": "sha256-0bed7aa664d8", "other": "obraz jest trzymany w pamięci podczas kodowania" }, "Joint.format/xlsx.rows.columns": { "hash": "sha256-e1ef283f3c5d", "other": "arkusz powstaje w pamięci, zanim zostanie spakowany" }, + "Label.algorithm": { "hash": "sha256-d704d8af25be", "other": "Algorytm" }, "Label.allow": { "hash": "sha256-e213c161d5ce", "other": "Dozwolone" }, "Label.author": { "hash": "sha256-d95082a2ee57", "other": "Autor" }, "Label.bit_depth": { "hash": "sha256-cc62f2e89a82", "other": "Głębia bitowa" }, @@ -152,7 +156,9 @@ "Label.entry_mode": { "hash": "sha256-4a17440263ca", "other": "Uprawnienia plików" }, "Label.entry_owner": { "hash": "sha256-d7bf94c5f409", "other": "Właściciel plików" }, "Label.entry_size": { "hash": "sha256-1190635edb6f", "other": "Rozmiar plików w środku" }, + "Label.expected": { "hash": "sha256-ca99b7f1b14e", "other": "Oczekiwana suma" }, "Label.far-over": { "hash": "sha256-874367c30d4e", "other": "Daleko ponad limit" }, + "Label.file": { "hash": "sha256-50009ce1da4d", "other": "Plik" }, "Label.format": { "hash": "sha256-2f343666aaa8", "other": "Format" }, "Label.formats": { "hash": "sha256-9f01769a4278", "other": "Formaty" }, "Label.formatting": { "hash": "sha256-29d4198e41d8", "other": "Układ" }, @@ -196,7 +202,9 @@ "Question.tabular-import": { "hash": "sha256-f2132eb34797", "other": "Czy import tabeli poradzi sobie z tym, co eksportują prawdziwe narzędzia?" }, "Question.text-encoding": { "hash": "sha256-766ce93a4d3c", "other": "Czy mój czytnik wie, w jakim kodowaniu jest plik, czy zgaduje?" }, "Question.upload-validation": { "hash": "sha256-950469ca4b3a", "other": "Czy mój formularz przesyłania plików przyjmuje to, co powinien, i odrzuca resztę?" }, + "Shape.a checksum in hexadecimal": { "hash": "sha256-62cb052ebc94", "other": "suma kontrolna zapisana szesnastkowo" }, "Shape.a date such as 2024-02-29 or 2024-02-29T13:45:00+02:00, or none": { "hash": "sha256-a40bd1decdc9", "other": "data, np. 2024-02-29 albo 2024-02-29T13:45:00+02:00, albo none" }, + "Shape.algorithm names separated by commas, or all": { "hash": "sha256-bba80a8dec88", "other": "nazwy algorytmów rozdzielone przecinkami albo all" }, "Shape.any text": { "hash": "sha256-06c83efcd22e", "other": "dowolny tekst" }, "Shape.extensions separated by commas": { "hash": "sha256-69f0be0e37e3", "other": "rozszerzenia rozdzielone przecinkami" }, "Shape.format ids separated by commas": { "hash": "sha256-cc1ca5dd954c", "other": "identyfikatory formatów rozdzielone przecinkami" }, @@ -204,6 +212,8 @@ "Shape.sizes separated by commas": { "hash": "sha256-f7e5e8c54e6f", "other": "rozmiary rozdzielone przecinkami" }, "Shape.the id of a format, as tfg formats lists them": { "hash": "sha256-b6784882fdeb", "other": "identyfikator formatu, tak jak wypisuje go tfg formats" }, "Shape.the password, in plain text": { "hash": "sha256-eb266034b6a8", "other": "hasło, zwykłym tekstem" }, + "Tool.checksum.Detail": { "hash": "sha256-f3e1a6e1c983", "other": "Liczy sumę kontrolną pliku i porównuje ją z tą, którą dostałeś - ze strony pobierania, z opisu wydania albo od kogoś z zespołu." }, + "Tool.checksum.Question": { "hash": "sha256-f7c87f2771e3", "other": "Czy ten plik jest tym, za który się podaje?" }, "Unit.bytes": { "hash": "sha256-277089d91c0b", "other": "bajtów" }, "Unit.columns": { "hash": "sha256-0e624a1a135e", "other": "kolumn" }, "Unit.entries per second": { "hash": "sha256-90ad5144c654", "other": "wpisów na sekundę" }, diff --git a/internal/gui/text/registry.go b/internal/gui/text/registry.go index 0212d04b..7c0a9dec 100644 --- a/internal/gui/text/registry.go +++ b/internal/gui/text/registry.go @@ -2,6 +2,7 @@ package text import ( "strconv" + "strings" "github.com/donislawdev/TestingFilesGenerator/internal/core" ) @@ -49,6 +50,23 @@ func FormatOwner(id string) Owner { return Owner("format/" + id) } func PresetOwner(id string) Owner { return Owner("preset/" + id) } func DamageOwner(id string) Owner { return Owner("damage/" + id) } +// ToolOwner is the fourth: the settings of a tool on the Tools tab. +func ToolOwner(id string) Owner { return Owner(toolPrefix + id) } + +const toolPrefix = "tool/" + +// WrittenAs is what the button beside a declared setting says about writing +// it down: the key a recipe writes it under, or for a tool - which no recipe +// runs - the flag of tfg tool. A sentence about a recipe beside a setting +// nobody can put in one would send somebody looking for a file that cannot +// hold it. +func WrittenAs(o Owner, key string) string { + if strings.HasPrefix(string(o), toolPrefix) { + return ToolFlag(key) + } + return SettingKey(key) +} + // The keys, built here and nowhere else - exported for the guard that writes // registry/en.json out of the registries, so the window and the guard cannot // come to ask for two different things. @@ -62,6 +80,13 @@ func QuestionKey(preset string) string { return "Question." + preset } func CatchKey(preset string, n int) string { return "Catch." + preset + "." + strconv.Itoa(n) } func NoteKey(preset, about string) string { return "Note." + preset + "." + about } +// The words of a tool, under keys of their own rather than beside a preset's: +// a tool and a preset may one day share a name, and "Question." would then +// be one key for two sentences. +func ToolQuestionKey(id string) string { return "Tool." + id + ".Question" } +func ToolDetailKey(id string) string { return "Tool." + id + ".Detail" } +func InputKey(id, name string) string { return "Input." + id + "." + name } + // lookup is say for a sentence whose key is made of identifiers and whose // English comes from a registry rather than from this package. Nothing to say // stays nothing: a setting declared without a sentence has no entry to find. @@ -117,6 +142,15 @@ func PresetNote(preset, about, english string) string { return lookup(NoteKey(preset, about), english) } +// ToolQuestion is the question a tool answers, which is its title. +func ToolQuestion(id, english string) string { return lookup(ToolQuestionKey(id), english) } + +// ToolDetail is the sentence saying what a tool does. +func ToolDetail(id, english string) string { return lookup(ToolDetailKey(id), english) } + +// ToolInput is the sentence beside what a tool works on. +func ToolInput(id, name, english string) string { return lookup(InputKey(id, name), english) } + // HumanBytes is core.HumanBytes with the decimal mark of the window's language. // // The pseudo language keeps the mark the number was written with: it disguises diff --git a/internal/gui/text/registrywords.go b/internal/gui/text/registrywords.go index 5f8231f9..9f97f79f 100644 --- a/internal/gui/text/registrywords.go +++ b/internal/gui/text/registrywords.go @@ -6,6 +6,7 @@ import ( "github.com/donislawdev/TestingFilesGenerator/internal/damage" "github.com/donislawdev/TestingFilesGenerator/internal/format" "github.com/donislawdev/TestingFilesGenerator/internal/preset" + "github.com/donislawdev/TestingFilesGenerator/internal/tool" ) // RegistryWord is one sentence of the registries the window shows, under the @@ -37,6 +38,9 @@ func RegistryWords() []RegistryWord { for _, d := range damage.All() { w.settings(DamageOwner(d.ID), "the "+d.ID+" damage", d.Parameters) } + for _, d := range tool.All() { + w.tool(d) + } return w.sorted() } @@ -74,6 +78,17 @@ func (w registryWords) preset(p preset.Preset) { } } +func (w registryWords) tool(d tool.Descriptor) { + whose := "the " + d.ID + " tool" + w.add(ToolQuestionKey(d.ID), d.Question, "The question "+whose+" answers - its name in the list of tools and the title over it.") + w.add(ToolDetailKey(d.ID), d.Detail, "One sentence under the question of "+whose+", saying what it does.") + for _, in := range d.Inputs { + w.add(LabelKey(in.Name), EnglishLabel(in.Name), "The name beside the box of what "+whose+" works on.") + w.add(InputKey(d.ID, in.Name), in.Detail, "The sentence under the "+in.Name+" box of "+whose+".") + } + w.settings(ToolOwner(d.ID), whose, d.Settings) +} + func (w registryWords) settings(owner Owner, whose string, declared []format.Property) { for _, p := range declared { w.add(LabelKey(p.Name), EnglishLabel(p.Name), "The name beside the box of a setting a recipe writes as "+p.Name+".") diff --git a/internal/gui/text/screens.go b/internal/gui/text/screens.go index 46822e65..1bbb827a 100644 --- a/internal/gui/text/screens.go +++ b/internal/gui/text/screens.go @@ -882,3 +882,77 @@ func PreferencesForgetWhat() string { func PreferencesForgotten() string { return say("PreferencesForgotten", "Forgotten. The next window opens the way the first one did.") } + +// The Tools tab - docs/NARZEDZIA-SUMY-2026-09-29.md. What each tool says about +// itself comes from its declaration through registry.go. These are the words +// of the screen around it. + +func TabTools() string { return say("TabTools", "Tools") } + +func SubtitleTools() string { + return say("SubtitleTools", "Small things to do with files you already have. Every tool here is on the command line too, as tfg tool.") +} + +func SectionTool() string { return say("SectionTool", "The question") } +func SectionToolInput() string { return say("SectionToolInput", "What it works on") } +func SectionToolResult() string { return say("SectionToolResult", "Result") } + +func FieldTool() string { return say("FieldTool", "Tool") } +func HintTool() string { return say("HintTool", "What you want to find out.") } +func DetailTool() string { + return say("DetailTool", "What the tool works on and its settings change with the tool chosen.") +} + +// ButtonRunTool starts the tool chosen. One word for every tool, because the +// question over it already says what it will do. +func ButtonRunTool() string { return say("ButtonRunTool", "Run") } + +// ButtonCopy puts one value of a result on the clipboard. +func ButtonCopy() string { return say("ButtonCopy", "Copy") } + +// ToolNothingYet is the result before the first run, so the section says what +// will appear there rather than standing empty. +func ToolNothingYet() string { + return say("ToolNothingYet", "Nothing worked out yet. Choose what to work on and press Run.") +} + +// ToolReading is the line under the bar while a tool reads. +func ToolReading(done, total string) string { + return sayf("ToolReading", "Read {{.Done}} of {{.Total}}.", map[string]any{"Done": done, "Total": total}) +} + +// ToolStopped is said when Cancel stopped a tool. A tool only reads, so +// nothing is left half done anywhere. +func ToolStopped() string { + return say("ToolStopped", "Stopped before it finished. A tool only reads, so nothing was changed.") +} + +// ToolCopied says a value is on the clipboard, since pressing Copy changes +// nothing that can be seen. +func ToolCopied(what string) string { + return sayf("ToolCopied", "The {{.What}} is on the clipboard.", map[string]any{"What": what}) +} + +// ToolMatches and ToolDoesNotMatch are tool.Verdict.Said in the window's +// language. A guard holds their English to that sentence, which is what the +// command line prints. +func ToolMatches(about, got string) string { + return sayf("ToolMatches", "Matches: the {{.About}} is {{.Got}}, as expected.", map[string]any{"About": about, "Got": got}) +} + +func ToolDoesNotMatch(about, got, wanted string) string { + return sayf("ToolDoesNotMatch", "Does not match: the {{.About}} is {{.Got}} and {{.Wanted}} was expected.", + map[string]any{"About": about, "Got": got, "Wanted": wanted}) +} + +// ToolInputWrittenAs is what the button beside the box of what a tool works +// on says about writing it down: a path, with no flag in front of it. +func ToolInputWrittenAs(tool string) string { + return sayf("ToolInputWrittenAs", "Written as the path after tfg tool {{.Tool}} on the command line.", map[string]any{"Tool": tool}) +} + +// ToolFlag is what the button beside a tool's setting says about writing it +// down. A tool is run by no recipe, so the flag is what there is to write. +func ToolFlag(key string) string { + return sayf("ToolFlag", "Written as --{{.Key}} after tfg tool on the command line.", map[string]any{"Key": key}) +} diff --git a/internal/gui/window/generate.go b/internal/gui/window/generate.go index 080f5865..9e7dadaa 100644 --- a/internal/gui/window/generate.go +++ b/internal/gui/window/generate.go @@ -52,6 +52,16 @@ type Host interface { // which is what lets a guard press the button on a machine with no screen. ChooseDirectory(func(string)) + // ChooseFile asks the person for one existing file, for a tool on the Tools + // tab to read, and calls back with its path or with nothing - always, for + // the reason ChooseDirectory gives. + ChooseFile(func(string)) + + // Copy puts text on the system's clipboard, on a press somebody made - the + // checksum a tool worked out, so it can be pasted where it is needed. Only + // a real window has a clipboard to put it on, and a stand in records it. + Copy(text string) + // Canvas is what the screens need for the keyboard: a shortcut is registered // on it, and so is where the keyboard starts. // @@ -414,7 +424,7 @@ func (g *Generate) settingsSection() []fyne.CanvasObject { ), g.sections.section(sectionOutput, text.SectionOutput(), parts.Wide(add(engine.SettingOutDir, text.FieldOutputDir(), text.HintOutputDir(), g.tips.Say(text.DetailOutputDir()), - chooserFor(g.host, g.outDir))), + chooserFor(g.outDir, g.host.ChooseDirectory))), add(engine.SettingSeed, text.FieldSeed(), text.HintSeed(), g.tips.Say(text.DetailSeed()), parts.Numeric(g.seed)), g.fields.AddToggle(engine.SettingLabel, text.FieldLabel(), "", g.tips.Say(text.DetailLabel()), g.label), diff --git a/internal/gui/window/open.go b/internal/gui/window/open.go index b81e9923..0be84b17 100644 --- a/internal/gui/window/open.go +++ b/internal/gui/window/open.go @@ -18,6 +18,7 @@ const ( tabGenerate = "generate" tabPresets = "presets" tabRecipe = "recipe" + tabTools = "tools" tabPrefs = "preferences" tabAbout = "about" ) @@ -38,6 +39,7 @@ func Open(h Host) fyne.Size { gen := NewGenerate(h) pre := NewPreset(h) rec := NewRecipe(h) + tools := NewTools(h) runners := []*runner{gen.runner, pre.runner, rec.runner} // The way out is wired below, once everything it stops exists. The screen // is built now, so it is handed a way to reach it rather than the thing. @@ -63,6 +65,7 @@ func Open(h Host) fyne.Size { &parts.Tab{ID: tabGenerate, Text: text.TabOneTarget(), Content: gen.Object()}, &parts.Tab{ID: tabPresets, Text: text.TabPresets(), Content: pre.Object()}, &parts.Tab{ID: tabRecipe, Text: text.TabRecipe(), Content: rec.Object()}, + &parts.Tab{ID: tabTools, Text: text.TabTools(), Content: tools.Object()}, &parts.Tab{ID: tabPrefs, Text: text.TabPreferences(), Content: prefs.Object()}, &parts.Tab{ID: tabAbout, Text: text.TabAbout(), Content: About(h)}, ) @@ -90,6 +93,11 @@ func Open(h Host) fyne.Size { tabRecipe: rec, } showing := tabGenerate + // looking is the tab on show, whatever it is - which is what a key acts on. + // Not showing, which stays on the last screen with a directory: until + // 2026-09-30 the keyboard read that one, so Ctrl+Enter on the Tools tab, + // on About or on Preferences ran Generate on a screen nobody could see. + looking := tabGenerate offerWhereItLastWrote(h, working) @@ -101,6 +109,7 @@ func Open(h Host) fyne.Size { tabGenerate: gen, tabPresets: pre, tabRecipe: rec, + tabTools: tools, } // The keyboard starts on the first field of the screen somebody is looking @@ -119,6 +128,7 @@ func Open(h Host) fyne.Size { if arriving { showing = item.ID } + looking = item.ID // The keyboard follows the person to the screen they moved to. Without // this it stays on a control of the screen they left, which is a Tab // that starts somewhere nobody can see. @@ -133,24 +143,17 @@ func Open(h Host) fyne.Size { // One wait for quiet for the whole window, told by every screen, and // stopped with them when the window closes - see tidy.go. quiet := tidyWhenLeftAlone(h, runners...) - leave = closeCleanly(h, []interface{ Stop() }{gen, pre, rec, quiet}, working, &showing) + leave = closeCleanly(h, []interface{ Stop() }{gen, pre, rec, tools, quiet}, working, &showing) // Restart now stands down while any screen is making files, and stands up // again when it stops - told rather than asked, so the button is right // the moment a run ends while the Preferences tab is on show. for _, r := range runners { r.busy.changed = prefs.BusyChanged } - offerSettling(h, []interface{ Settled() }{gen, pre, rec}) + offerSettling(h, []interface{ Settled() }{gen, pre, rec, tools}) offerHolding(h, []interface{ HoldBeforeFinishing(func()) }{gen, pre, rec}) - // One table for the window, handed to the boxes of every screen. Wired here - // rather than in each constructor because the table belongs to the window - // and the screens are built before it exists. - shortcuts := parts.NewShortcuts() - for _, screen := range []interface{ Fields() *parts.Fields }{gen, pre, rec} { - screen.Fields().PassShortcutsTo(shortcuts.Deliver) - } - wireKeyboard(h, keyed, &showing, shortcuts) + wireKeyboard(h, keyed, &looking) // The window still opens on the work rather than on the notice, which is // the owner's decision of 2026-08-05 and is now a property of which tab is @@ -247,13 +250,26 @@ func offerWhereItLastWrote(h Host, working map[string]interface { // of the ceiling. The ceiling is a ratchet, so the answer is a split and never // a higher number. // -// showing is a pointer because the answer changes as somebody moves between +// looking is a pointer because the answer changes as somebody moves between // screens, and a shortcut is about the screen being looked at WHEN IT IS -// PRESSED rather than when it was registered. -func wireKeyboard(h Host, keyed map[string]keyboardScreen, showing *string, table *parts.Shortcuts) { +// PRESSED rather than when it was registered. A tab the map does not know - +// About, Preferences - takes no shortcut at all. +// +// One table for the window, handed to the boxes of every screen the keyboard +// reaches. Wired here rather than in each constructor because the table +// belongs to the window and the screens are built before it exists. From the +// map rather than from a list of its own since 2026-09-30: the list named +// three screens, the Tools tab was the fourth in the map and not in the list, +// and Ctrl+Enter typed in its box did nothing. A screen the keyboard reaches +// is now a screen whose boxes pass it on, with nothing to remember. +func wireKeyboard(h Host, keyed map[string]keyboardScreen, looking *string) { + table := parts.NewShortcuts() + for _, screen := range keyed { + screen.Fields().PassShortcutsTo(table.Deliver) + } on := func(act func(keyboardScreen)) func(fyne.Shortcut) { return func(fyne.Shortcut) { - if screen, ok := keyed[*showing]; ok { + if screen, ok := keyed[*looking]; ok { act(screen) } } @@ -337,6 +353,8 @@ type keyboardScreen interface { PressPreview() PressCancel() FirstField() fyne.Focusable + // Fields is the boxes, which pass on the shortcuts they have no use for. + Fields() *parts.Fields } // FirstScreen is what the window shows when it opens, without a window to put @@ -384,11 +402,16 @@ func donate(h Host, look parts.Look) *parts.Button { // // The box stays editable. A picker that replaces typing takes away pasting a // path somebody sent you, which is how most of these get filled in. -func chooserFor(host Host, box *parts.Entry) fyne.CanvasObject { +// +// pick is which picker the button opens - Host.ChooseDirectory for where files +// go, Host.ChooseFile for a file a tool reads. One box and one button for +// both, so a path field looks and behaves the same whatever it names (GUI +// rule 2). +func chooserFor(box *parts.Entry, pick func(func(string))) fyne.CanvasObject { choose := parts.NewButton(parts.Secondary, text.ButtonChoose(), func() { - host.ChooseDirectory(func(dir string) { - if dir != "" { - box.SetText(dir) + pick(func(path string) { + if path != "" { + box.SetText(path) } }) }) diff --git a/internal/gui/window/preset.go b/internal/gui/window/preset.go index 8e352067..35e534c1 100644 --- a/internal/gui/window/preset.go +++ b/internal/gui/window/preset.go @@ -111,7 +111,7 @@ func NewPreset(host Host, links ...fyne.CanvasObject) *Preset { p.sections.section(sectionSettings, text.SectionSettings(), p.paramBox), p.sections.section(sectionOutput, text.SectionOutput(), parts.Wide(p.fields.Add(engine.SettingOutDir, text.FieldOutputDir(), text.HintOutputDir(), - p.tips.Say(text.DetailOutputDir()), chooserFor(p.host, p.outDir))), + p.tips.Say(text.DetailOutputDir()), chooserFor(p.outDir, p.host.ChooseDirectory))), p.fields.Add(engine.SettingSeed, text.FieldSeed(), text.HintSeed(), p.tips.Say(text.DetailSeed()), parts.Numeric(p.seed)), ), diff --git a/internal/gui/window/recipe.go b/internal/gui/window/recipe.go index 2e298732..f512569a 100644 --- a/internal/gui/window/recipe.go +++ b/internal/gui/window/recipe.go @@ -563,7 +563,7 @@ func (r *Recipe) outputSection() fyne.CanvasObject { r.fields.Require(recipe.KeyOutputDir) return r.sections.section(sectionOutput, text.SectionOutput(), parts.Wide(r.fields.Add(recipe.KeyOutputDir, text.FieldOutputDir(), text.HintOutputDir(), - r.tips.Say(text.DetailOutputDir()), chooserFor(r.host, r.outDir))), + r.tips.Say(text.DetailOutputDir()), chooserFor(r.outDir, r.host.ChooseDirectory))), r.fields.Add(recipe.KeyOutputManifest, text.FieldManifest(), text.HintManifest(), r.tips.Say(text.DetailManifest()), parts.Text(r.manifest)), r.fields.Add(recipe.KeySeed, text.FieldSeed(), text.HintSeed(), diff --git a/internal/gui/window/run.go b/internal/gui/window/run.go index cc302bbf..03bb5228 100644 --- a/internal/gui/window/run.go +++ b/internal/gui/window/run.go @@ -380,7 +380,7 @@ func newRunner(wait later) *runner { cancel := parts.NewButton(parts.Secondary, text.ButtonCancel(), r.onCancel).InTheBar() cancel.Disable() cancel.Hide() - r.busy = &busy{fields: r.fields, preview: r.previewBtn, generate: r.generateBtn, + r.busy = &busy{fields: r.fields, starters: []*parts.Button{r.previewBtn, r.generateBtn}, cancel: cancel, bar: bar, later: wait} r.offer = newOffers(r.busy.relay) diff --git a/internal/gui/window/runbusy.go b/internal/gui/window/runbusy.go index 24bb3935..6840ac9b 100644 --- a/internal/gui/window/runbusy.go +++ b/internal/gui/window/runbusy.go @@ -56,9 +56,12 @@ type busy struct { // was put on and never twice. worn bool - fields *parts.Fields - preview *parts.Button - generate *parts.Button + fields *parts.Fields + // starters are the buttons that start work - Preview and Generate on a + // work screen, the one action of a tool on the Tools tab. A list since + // 2026-09-29, when a second kind of screen came to need this state: the + // face is the same whatever the work is. + starters []*parts.Button cancel *parts.Button bar *parts.Progress // also are controls that are neither fields nor run buttons and still @@ -135,8 +138,9 @@ func (b *busy) wear(face busyFace) { for _, control := range b.also { control.Disable() } - b.preview.Disable() - b.generate.Disable() + for _, starter := range b.starters { + starter.Disable() + } // Cancel is hidden rather than greyed when there is nothing to cancel, // asked for on 2026-08-11 after looking at the window. A permanently dead // control is a question the screen keeps asking and answering itself, and @@ -162,8 +166,9 @@ func (b *busy) undress() { for _, control := range b.also { control.Enable() } - b.preview.Enable() - b.generate.Enable() + for _, starter := range b.starters { + starter.Enable() + } b.cancel.Disable() b.cancel.Hide() b.bar.Hide() diff --git a/internal/gui/window/sections.go b/internal/gui/window/sections.go index 53a60406..84464e04 100644 --- a/internal/gui/window/sections.go +++ b/internal/gui/window/sections.go @@ -14,6 +14,9 @@ const ( sectionPreset = "preset" sectionSettings = "settings" sectionBase = "base" + sectionTool = "tool" + sectionToolInput = "tool-input" + sectionToolResult = "tool-result" ) // sections are the panels of a work screen, each of which folds away - the diff --git a/internal/gui/window/tools.go b/internal/gui/window/tools.go new file mode 100644 index 00000000..1e4ba4f8 --- /dev/null +++ b/internal/gui/window/tools.go @@ -0,0 +1,334 @@ +package window + +import ( + "context" + "errors" + "time" + + "fyne.io/fyne/v2" + "fyne.io/fyne/v2/container" + "fyne.io/fyne/v2/widget" + + "github.com/donislawdev/TestingFilesGenerator/internal/core" + "github.com/donislawdev/TestingFilesGenerator/internal/gui/parts" + "github.com/donislawdev/TestingFilesGenerator/internal/gui/text" + "github.com/donislawdev/TestingFilesGenerator/internal/tool" + _ "github.com/donislawdev/TestingFilesGenerator/internal/tool/all" +) + +// Tools is the screen of the tools - docs/NARZEDZIA-SUMY-2026-09-29.md, the +// owner's decisions of 2026-09-29. +// +// Nothing on it is written for one tool. The list is the registry, the boxes +// are what the chosen tool declares - drawn by the same DeclaredFields the +// other three screens draw a format's settings with - and the result is the +// rows the tool hands back. So a tool added tomorrow is on this screen the +// day it is registered, the same as on the command line, which is D1 kept by +// construction rather than by remembering. +// +// The request is the only state (G3 read for a screen with no recipe): what +// is in the boxes when Run is pressed is what the tool is asked, the same +// request tfg tool builds from its flags. +type Tools struct { + host Host + tips *parts.Tips + fields *parts.Fields + sections *sections + + pick *parts.Chooser + titles map[string]string + about *fyne.Container + work *fyne.Container + result *fyne.Container + + chosen tool.Descriptor + form toolForm + + busy *busy + status *widget.Label + fault *parts.ErrorArea + job *toolJob + + body fyne.CanvasObject +} + +// toolForm is the boxes of the chosen tool: what goes into a request, before +// it is asked. Held apart from the screen because the three are drawn, thrown +// away and read together, and never one without the others - which is also +// what kept Tools under the ceiling on the fields of a type (2026-09-30). +type toolForm struct { + // fixed is how many fields belong to the screen whatever tool is chosen. + // The chosen tool's come after them and are replaced with it. + fixed int + inputs map[string]*parts.Entry + settings []parts.PropertyField +} + +// request is what the boxes say, as the tool is asked it - the same request +// tfg tool builds from its flags. +func (f toolForm) request() tool.Request { + in := tool.Request{Inputs: map[string]string{}, Values: map[string]string{}} + for name, box := range f.inputs { + in.Inputs[name] = box.Text + } + for _, field := range f.settings { + in.Values[field.Name] = field.Value() + } + return in +} + +// settingTool is the key the box choosing the tool goes under. Not a flag of +// anything - the tool is the word after "tfg tool" - but a key like every +// other box has, so the registry of fields has no exception in it. +const settingTool = "tool" + +// NewTools builds the screen, with the first tool of the registry chosen. +func NewTools(host Host) *Tools { + t := &Tools{host: host, tips: parts.NewTips(), fields: parts.NewFields(), sections: newSections()} + t.titles = map[string]string{} + questions := make([]string, 0, len(tool.Names())) + for _, d := range tool.All() { + q := text.ToolQuestion(d.ID, d.Question) + t.titles[q] = d.ID + questions = append(questions, q) + } + t.pick = parts.NewChooser(questions, t.onChosen) + t.about = parts.FieldColumn() + t.work = parts.Grid() + t.result = parts.FieldColumn() + + run := parts.NewButton(parts.Primary, text.ButtonRunTool(), t.PressGenerate).InTheBar() + cancel := parts.NewButton(parts.Secondary, text.ButtonCancel(), t.PressCancel).InTheBar() + cancel.Disable() + cancel.Hide() + bar := parts.NewProgress() + bar.Hide() + t.busy = &busy{fields: t.fields, starters: []*parts.Button{run}, cancel: cancel, bar: bar, later: host.Later} + t.busy.row = parts.ButtonRow(run, cancel) + t.status = widget.NewLabel("") + t.status.Wrapping = fyne.TextWrapWord + t.fault = parts.NewErrorArea() + + t.body = t.tips.Over(container.NewBorder( + nil, + parts.ActionBar(rail(donateButton(host)), t.busy.row, roomToSpeak(bar, t.status, t.fault)), + nil, nil, + container.NewVScroll(parts.Screen( + parts.Titled(text.TabTools(), text.SubtitleTools()), + t.sections.section(sectionTool, text.SectionTool(), + t.fields.Add(settingTool, text.FieldTool(), text.HintTool(), t.tips.Say(text.DetailTool()), t.pick), + t.about, + ), + t.sections.section(sectionToolInput, text.SectionToolInput(), t.work), + t.sections.section(sectionToolResult, text.SectionToolResult(), t.result), + )), + )) + t.form.fixed = t.fields.Len() + if len(questions) > 0 { + t.pick.SetSelected(questions[0]) + } + offerSettling(host, []interface{ Settled() }{t}) + return t +} + +// Object is the screen, for the tab that holds it. +func (t *Tools) Object() fyne.CanvasObject { return t.body } + +// FirstField is where the keyboard starts: which tool, because every box under +// it is drawn from that answer. +func (t *Tools) FirstField() fyne.Focusable { return t.pick } + +// Fields is the boxes of the screen, for the window to hand its shortcuts to - +// so Ctrl+Enter pressed in the box naming the file runs the tool, the way it +// runs the work of every other screen. +func (t *Tools) Fields() *parts.Fields { return t.fields } + +// PressPreview does nothing: a tool reads and says, so there is no cost to +// work out before it runs. On the screen for the keyboard's sake - the window +// asks every screen the same four things. +func (t *Tools) PressPreview() {} + +// PressCancel stops the tool that is running, if one is. +func (t *Tools) PressCancel() { t.Stop() } + +// Stop ends the tool that is running and waits for it, or does nothing. Safe +// to call at any time, which is what lets closing the window ask every screen. +func (t *Tools) Stop() { + if t.job != nil { + t.job.stop() + } +} + +// Settled waits for the tool that is running to finish, for a guard. +func (t *Tools) Settled() { + if t.job != nil { + <-t.job.done + } +} + +// onChosen draws what the chosen tool works on and what it takes. +func (t *Tools) onChosen(question string) { + d, err := tool.Get(t.titles[question]) + if err != nil { + t.fault.Say(core.ShownText(err.Error())) + return + } + t.chosen = d + t.about.RemoveAll() + t.about.Add(parts.Prose(text.ToolDetail(d.ID, d.Detail))) + t.about.Refresh() + + t.fields.KeepFirst(t.form.fixed) + t.work.RemoveAll() + t.form.inputs = map[string]*parts.Entry{} + for _, in := range d.Inputs { + box := entry("", "") + t.form.inputs[in.Name] = box + t.work.Add(parts.Wide(t.fields.Add(in.Name, text.SettingLabel(in.Name), text.ToolInput(d.ID, in.Name, in.Detail), + t.tips.Say(text.ToolInputWrittenAs(d.ID)), chooserFor(box, t.host.ChooseFile)))) + } + settings, objects := parts.DeclaredFields(text.ToolOwner(d.ID), d.Settings, t.fields, t.tips) + t.form.settings = settings + for _, o := range objects { + t.work.Add(o) + } + t.work.Refresh() + t.showResult(nil) +} + +// PressGenerate runs the chosen tool - the name the window's keyboard asks +// every screen by. +func (t *Tools) PressGenerate() { + if t.busy.occupied { + return + } + t.fields.ClearAll() + t.fault.Clear() + showOn(t.status, "") + t.job = startTool(t.chosen, t.form.request(), t) +} + +// progressed and finished are what a running tool tells the screen, on the +// interface thread. +func (t *Tools) progressed(done, total int64) { + t.busy.bar.SetValue(float64(core.Percent(done, total))) + showOn(t.status, text.ToolReading(text.HumanBytes(done), text.HumanBytes(total))) +} + +func (t *Tools) finished(r tool.Result, err error) { + t.busy.set(false, busyFace{}) + showOn(t.status, "") + if err != nil { + t.refuse(err) + return + } + t.showResult(&r) +} + +// refuse puts a refusal under the box it is about, or at the foot of the +// screen when it is about none - the same two places every screen uses. +func (t *Tools) refuse(err error) { + if errors.Is(err, context.Canceled) { + showOn(t.status, text.ToolStopped()) + return + } + var about interface{ AboutSetting() string } + if errors.As(err, &about) && t.fields.Mark(about.AboutSetting(), err) { + t.sections.openHolding(t.fields.Lookup(about.AboutSetting()).Control) + return + } + t.fault.Say(core.ShownText(err.Error())) +} + +// showResult draws what a run found, or what will appear there before one. +// +// The canvas is told once, after the last change - a deferred word to it read +// as coming before the changes to the guard that holds this +// (TestABoxThatGainsOrLosesAPieceSaysSo), which cannot see when a defer runs. +func (t *Tools) showResult(r *tool.Result) { + t.result.RemoveAll() + for _, o := range t.resultObjects(r) { + t.result.Add(o) + } + t.result.Refresh() +} + +// resultObjects is what a run found, as the section shows it: the rows and the +// verdict, or a sentence saying nothing has run yet. +func (t *Tools) resultObjects(r *tool.Result) []fyne.CanvasObject { + if r == nil { + return []fyne.CanvasObject{parts.Prose(text.ToolNothingYet())} + } + rows := parts.Grid() + for _, row := range r.Rows { + rows.Add(t.resultRow(row)) + } + out := []fyne.CanvasObject{rows} + switch r.Verdict.Outcome { + case tool.Match: + out = append(out, parts.Prose(text.ToolMatches(r.Verdict.About, r.Verdict.Got))) + case tool.Mismatch: + verdict := parts.NewErrorArea() + verdict.Say(text.ToolDoesNotMatch(r.Verdict.About, r.Verdict.Got, r.Verdict.Wanted)) + out = append(out, verdict.Object()) + case tool.Unasked: + } + return out +} + +// resultRow is one row of a result: its first cell as the name in the column +// of names, the rest beside it, and a way to copy what is beside it. The name +// is data - an algorithm, a file - and is shown as it is (G8). +func (t *Tools) resultRow(row []string) fyne.CanvasObject { + if len(row) == 0 { + return parts.Prose("") + } + value := "" + if len(row) > 1 { + value = row[len(row)-1] + } + copyIt := parts.NewButton(parts.Secondary, text.ButtonCopy(), func() { + t.host.Copy(value) + showOn(t.status, text.ToolCopied(row[0])) + }) + return parts.Wide(t.fields.Named(row[0], parts.NoDetail, + container.NewBorder(nil, nil, nil, copyIt, parts.Prose(value)))) +} + +// toolJob is one run of a tool beside the window. +// +// Beside it because a file of gigabytes takes seconds to read and a window +// that waits for it is a window the desktop calls not responding - the same +// reason a run of the engine happens beside it. The stop is set before the +// goroutine starts, so closing the window at any moment of the run finds it +// and waits for the read to wind down (G7). +type toolJob struct { + stop func() + done chan struct{} +} + +// startTool runs one tool and tells the screen how it went. +func startTool(d tool.Descriptor, in tool.Request, screen *Tools) *toolJob { + ctx, cancel := context.WithCancel(context.Background()) + job := &toolJob{done: make(chan struct{})} + job.stop = func() { + cancel() + <-job.done + } + screen.busy.set(true, busyFace{stoppable: true, progressing: true}) + screen.busy.bar.SetValue(0) + limit := &throttle{} + progress := func(done, total int64) { + if !limit.allow(time.Now()) { + return + } + fyne.Do(func() { screen.progressed(done, total) }) + } + go func() { + defer cancel() + result, err := d.Start(ctx, in, progress) + fyne.Do(func() { screen.finished(result, err) }) + close(job.done) + }() + return job +} diff --git a/internal/tool/all/all.go b/internal/tool/all/all.go new file mode 100644 index 00000000..0b479c61 --- /dev/null +++ b/internal/tool/all/all.go @@ -0,0 +1,11 @@ +// Package all registers every built in tool. +// +// The same arrangement as internal/format/all, for the same reason: one place +// that puts the registry together, imported by both surfaces, so neither can +// know a tool the other does not. +package all + +import ( + // Each tool registers itself when its package is loaded. + _ "github.com/donislawdev/TestingFilesGenerator/internal/tool/checksum" +) diff --git a/internal/tool/checksum/checksum.go b/internal/tool/checksum/checksum.go new file mode 100644 index 00000000..75523915 --- /dev/null +++ b/internal/tool/checksum/checksum.go @@ -0,0 +1,326 @@ +// Package checksum is the tool that works out the checksums of a file and +// compares one with a checksum somebody was given. +// +// Five algorithms, in the order a person meets them: md5, sha1, sha256, +// sha512 and crc32. sha256 alone unless asked, because the others cost time +// nobody asked to spend - measured 2026-09-29 on 512 MiB in memory, five runs: +// sha256 2064 MB/s on its own, all five through one writer 305 MB/s +// (docs/NARZEDZIA-SUMY-2026-09-29.md section 2). +// +// md5 and sha1 are here because files are still published with them, not +// because they protect anything. Telling a download apart from the file its +// page describes is a comparison, and a comparison with a broken hash is +// still a comparison. The description of the setting says which to trust. +package checksum + +import ( + "context" + //nolint:gosec // md5 is offered to compare with checksums published as md5, and the setting says it proves nothing + "crypto/md5" + //nolint:gosec // sha1 for the same reason as md5, and said the same way + "crypto/sha1" + "crypto/sha256" + "crypto/sha512" + "encoding/hex" + "hash" + "hash/crc32" + "io" + "os" + "strings" + + "github.com/donislawdev/TestingFilesGenerator/internal/format" + "github.com/donislawdev/TestingFilesGenerator/internal/tool" +) + +// ID is the name of the tool, after "tfg tool". +const ID = "checksum" + +// The names of what the tool declares, used by the request and by both +// surfaces. +const ( + InputFile = "file" + SettingAlgorithm = "algorithm" + SettingExpect = "expected" +) + +// algorithm is one checksum this tool works out. +type algorithm struct { + name string + // digits is how long its checksum is in hexadecimal. Different for all + // five, which is what lets a pasted checksum say which one it is. + digits int + make func() hash.Hash +} + +// algorithms is every checksum this tool knows, in the order it prints them. +var algorithms = []algorithm{ + {name: "md5", digits: 32, make: md5.New}, + {name: "sha1", digits: 40, make: sha1.New}, + {name: "sha256", digits: 64, make: sha256.New}, + {name: "sha512", digits: 128, make: sha512.New}, + // IEEE, which is the one ZIP, gzip and PNG carry. Not the one cksum prints: + // that is another polynomial with the length folded in, and the setting + // says so, because the two look alike and never agree. + {name: "crc32", digits: 8, make: func() hash.Hash { return crc32.NewIEEE() }}, +} + +// Names is the algorithms this tool knows, in the order it prints them. +func Names() []string { + out := make([]string, 0, len(algorithms)) + for _, a := range algorithms { + out = append(out, a.name) + } + return out +} + +func init() { + tool.Register(tool.Descriptor{ + ID: ID, + Question: "Is this file the one it claims to be?", + Detail: "Works out the checksum of a file, and compares it with one you were given - " + + "from a download page, a release note or a colleague.", + Inputs: []tool.Input{{ + Name: InputFile, Kind: tool.File, + Detail: "The file to work out the checksums of. It is only read.", + }}, + Settings: []format.Property{ + { + Name: SettingAlgorithm, Kind: format.PropertyText, Default: "sha256", + Shape: "algorithm names separated by commas, or all", + Detail: "Which checksums to work out: md5, sha1, sha256, sha512 or crc32. " + + "Only sha256 and sha512 still show that nobody changed the file on purpose. " + + "crc32 is the one ZIP and PNG use, not the one cksum prints.", + }, + { + Name: SettingExpect, Kind: format.PropertyText, + Shape: "a checksum in hexadecimal", + Detail: "The checksum the file should have. The algorithm is told from its length, " + + "and worked out even when it is not chosen above.", + }, + }, + Columns: []string{"Algorithm", "Checksum"}, + Run: run, + }) +} + +// Checksums is what --json prints about one file. +type Checksums struct { + File string `json:"file"` + Bytes int64 `json:"bytes"` + Checksums map[string]string `json:"checksums"` + Expected *Expected `json:"expected,omitempty"` +} + +// Expected is the comparison, when a checksum was given. +type Expected struct { + Algorithm string `json:"algorithm"` + Checksum string `json:"checksum"` + Matches bool `json:"matches"` +} + +// run is the tool, on a request tool.Start has checked and defaulted. +func run(ctx context.Context, in tool.Request, progress tool.Progress) (tool.Result, error) { + chosen, err := choose(in.Values[SettingAlgorithm]) + if err != nil { + return tool.Result{}, err + } + expected, err := readExpected(in.Values[SettingExpect]) + if err != nil { + return tool.Result{}, err + } + if expected != nil && !chosen[expected.Algorithm] { + // Worked out although not chosen. The row shows it, so nothing + // happens that the person cannot see. + chosen[expected.Algorithm] = true + } + + path := in.Inputs[InputFile] + digests, size, err := digest(ctx, path, chosen, progress) + if err != nil { + return tool.Result{}, err + } + + out := Checksums{File: path, Bytes: size, Checksums: digests, Expected: expected} + result := tool.Result{Data: &out} + for _, a := range algorithms { + if sum, ok := digests[a.name]; ok { + result.Rows = append(result.Rows, []string{a.name, sum}) + } + } + if expected != nil { + expected.Matches = digests[expected.Algorithm] == expected.Checksum + result.Verdict = tool.Verdict{ + Outcome: tool.Mismatch, About: expected.Algorithm, + Wanted: expected.Checksum, Got: digests[expected.Algorithm], + } + if expected.Matches { + result.Verdict.Outcome = tool.Match + } + } + return result, nil +} + +// choose reads the algorithm setting into the set to work out. +// +// Spelled exactly, the way a closed set of choices is (O168): "SHA256" is +// refused with the list rather than understood, so what a recipe or a script +// writes means one thing everywhere. +func choose(raw string) (map[string]bool, error) { + chosen := map[string]bool{} + if strings.TrimSpace(raw) == "all" { + for _, a := range algorithms { + chosen[a.name] = true + } + return chosen, nil + } + for _, word := range strings.Split(raw, ",") { + word = strings.TrimSpace(word) + if word == "" { + continue + } + if find(word) == nil { + return nil, &UnknownAlgorithmError{Name: word} + } + chosen[word] = true + } + if len(chosen) == 0 { + return nil, &UnknownAlgorithmError{Name: raw} + } + return chosen, nil +} + +// find is the algorithm of a name, or nil. +func find(name string) *algorithm { + for i := range algorithms { + if algorithms[i].name == name { + return &algorithms[i] + } + } + return nil +} + +// readExpected reads a pasted checksum, or nothing when none was given. +// +// Either case is taken, because the same checksum is published both ways and +// neither is a spelling of something else - PowerShell prints capitals and +// sha256sum does not. Spaces at the ends are what a copy picks up. +func readExpected(raw string) (*Expected, error) { + sum := strings.ToLower(strings.TrimSpace(raw)) + if sum == "" { + return nil, nil + } + if _, err := hex.DecodeString(sum); err != nil || len(sum)%2 != 0 { + return nil, &ExpectedError{Given: raw} + } + for _, a := range algorithms { + if len(sum) == a.digits { + return &Expected{Algorithm: a.name, Checksum: sum}, nil + } + } + return nil, &ExpectedError{Given: raw, Digits: len(sum)} +} + +// digest reads a file once and works out every chosen checksum of it. +func digest(ctx context.Context, path string, chosen map[string]bool, progress tool.Progress) (map[string]string, int64, error) { + f, before, err := openRegular(path) + if err != nil { + return nil, 0, err + } + defer func() { _ = f.Close() }() + + hashes := map[string]hash.Hash{} + writers := make([]io.Writer, 0, len(chosen)) + for _, a := range algorithms { + if chosen[a.name] { + h := a.make() + hashes[a.name] = h + writers = append(writers, h) + } + } + read, err := copyWatching(ctx, io.MultiWriter(writers...), f, before.Size(), progress) + if err != nil { + return nil, 0, err + } + after, err := f.Stat() + if err != nil { + return nil, 0, err + } + if changed(before, after, read) { + return nil, 0, &ChangedError{Path: path} + } + + out := make(map[string]string, len(hashes)) + for name, h := range hashes { + out[name] = hex.EncodeToString(h.Sum(nil)) + } + return out, read, nil +} + +// openRegular opens a path for reading, and hands it back only when what was +// opened is a file. +// +// Followed through a link, the way a person naming a file means it - but what +// is at the end has to be a file. A directory cannot be read as one, and a +// pipe or a device can be read forever. +// +// Asked of the open file rather than of the name, since 2026-09-30. The name +// was asked first and opened second until then, and between the two looks it +// could come to stand for a pipe, which open then waited on for good - a +// review of #157 found it. Now there is one look, at what was opened, and the +// open itself does not wait (openForLooking). The price is that a device named +// on purpose is opened and closed again, never read. +func openRegular(path string) (*os.File, os.FileInfo, error) { + f, err := openForLooking(path) + if err != nil { + return nil, nil, err + } + info, err := f.Stat() + if err == nil && !info.Mode().IsRegular() { + err = &NotAFileError{Path: path, Directory: info.IsDir()} + } + if err == nil { + err = waitAgain(f) + } + if err != nil { + _ = f.Close() + return nil, nil, err + } + return f, info, nil +} + +// changed says whether the file moved under the read. A checksum of a file +// that was being written is a checksum of no version of it. +func changed(before, after os.FileInfo, read int64) bool { + return after.Size() != before.Size() || read != before.Size() || !after.ModTime().Equal(before.ModTime()) +} + +// chunk is how much is read between two looks at the context. A megabyte is a +// millisecond of sha256 and a few of the slowest algorithm, so a stop lands at +// once and the look costs nothing. +const chunk = 1 << 20 + +// copyWatching is io.Copy that stops when asked and says how far it got. +func copyWatching(ctx context.Context, dst io.Writer, src io.Reader, total int64, progress tool.Progress) (int64, error) { + buf := make([]byte, chunk) + var done int64 + for { + if err := ctx.Err(); err != nil { + return done, err + } + // What was read is written before the error is looked at, because a + // reader may hand back its last bytes and io.EOF in one call. Nothing + // read is nothing written, so there is no need to ask first. + n, err := src.Read(buf) + if _, werr := dst.Write(buf[:n]); werr != nil { + return done, werr + } + done += int64(n) + progress(done, total) + if err == io.EOF { + return done, nil + } + if err != nil { + return done, err + } + } +} diff --git a/internal/tool/checksum/open_unix.go b/internal/tool/checksum/open_unix.go new file mode 100644 index 00000000..3d256fab --- /dev/null +++ b/internal/tool/checksum/open_unix.go @@ -0,0 +1,36 @@ +//go:build !windows + +package checksum + +import ( + "os" + "syscall" +) + +// openForLooking opens a path for reading without waiting on whatever it +// names. +// +// A pipe opened for reading waits until something writes to it, which may be +// never. O_NONBLOCK makes the open return at once, so what was opened can be +// asked what it is before a byte is read - see openRegular. +func openForLooking(path string) (*os.File, error) { + return os.OpenFile(path, os.O_RDONLY|syscall.O_NONBLOCK, 0) +} + +// waitAgain takes the flag back off a descriptor that turned out to be a file. +// +// open(2) says the flag has no effect on a regular file, and in the same +// paragraph that a program should not depend on that staying so (man7.org, +// open(2), O_NONBLOCK, read 2026-09-30). A read that came back "try again" +// would end the checksum with an error nobody could act on. +func waitAgain(f *os.File) error { + raw, err := f.SyscallConn() + if err != nil { + return err + } + var set error + if err := raw.Control(func(fd uintptr) { set = syscall.SetNonblock(int(fd), false) }); err != nil { + return err + } + return set +} diff --git a/internal/tool/checksum/open_windows.go b/internal/tool/checksum/open_windows.go new file mode 100644 index 00000000..2c574338 --- /dev/null +++ b/internal/tool/checksum/open_windows.go @@ -0,0 +1,17 @@ +//go:build windows + +package checksum + +import "os" + +// openForLooking is a plain open on Windows. syscall.Open there maps the +// flags it knows and O_NONBLOCK is not one of them (syscall_windows.go, Open, +// read 2026-09-30), so asking for it would change nothing. What was opened is +// still asked what it is before a byte is read, the same as everywhere else - +// see openRegular. +func openForLooking(path string) (*os.File, error) { + return os.Open(path) +} + +// waitAgain has nothing to take back, since nothing was set. +func waitAgain(*os.File) error { return nil } diff --git a/internal/tool/checksum/refusals.go b/internal/tool/checksum/refusals.go new file mode 100644 index 00000000..99c288a3 --- /dev/null +++ b/internal/tool/checksum/refusals.go @@ -0,0 +1,138 @@ +package checksum + +import ( + "fmt" + "strings" + + "github.com/donislawdev/TestingFilesGenerator/internal/tool" +) + +// UnknownAlgorithmError is an algorithm this tool does not work out. +type UnknownAlgorithmError struct { + Name string +} + +// AboutSetting is the setting this is about, for the box it goes under. +func (e *UnknownAlgorithmError) AboutSetting() string { return SettingAlgorithm } + +// What happened. +func (e *UnknownAlgorithmError) What() string { + return fmt.Sprintf("there is no algorithm called %q", e.Name) +} + +// Why it is refused rather than matched to the nearest name. +func (e *UnknownAlgorithmError) Why() string { + return "an algorithm is named exactly, because two that sound alike give different checksums" +} + +// Instead is the list, from the table the tool works from. +func (e *UnknownAlgorithmError) Instead() string { + return "use " + strings.Join(Names(), ", ") + ", several separated by commas, or all on its own" +} + +func (e *UnknownAlgorithmError) Error() string { return tool.Sentence(e.What(), e.Why(), e.Instead()) } + +// Class says this is a mistake in the request. +func (e *UnknownAlgorithmError) Class() tool.Class { return tool.Asked } + +// ExpectedError is a checksum to compare with that is not one. +type ExpectedError struct { + Given string + // Digits is how long it was, when it was hexadecimal of a length no + // algorithm here has. Zero when it was not hexadecimal at all. + Digits int +} + +// AboutSetting is the setting this is about. +func (e *ExpectedError) AboutSetting() string { return SettingExpect } + +// What happened. +func (e *ExpectedError) What() string { + if e.Digits == 0 { + return fmt.Sprintf("%q is not a checksum written in hexadecimal", e.Given) + } + return fmt.Sprintf("a checksum %d digits long is not one this tool works out", e.Digits) +} + +// Why the length matters. +func (e *ExpectedError) Why() string { + return "the length of a checksum says which algorithm made it, and that is the one worked out to compare" +} + +// Instead is the lengths there are. +func (e *ExpectedError) Instead() string { + lengths := make([]string, 0, len(algorithms)) + for _, a := range algorithms { + lengths = append(lengths, fmt.Sprintf("%s %d", a.name, a.digits)) + } + return "paste the checksum alone, without the file name. The lengths are: " + strings.Join(lengths, ", ") +} + +func (e *ExpectedError) Error() string { return tool.Sentence(e.What(), e.Why(), e.Instead()) } + +// Class says this is a mistake in the request. +func (e *ExpectedError) Class() tool.Class { return tool.Asked } + +// NotAFileError is a path naming something that is not a file. +type NotAFileError struct { + Path string + Directory bool +} + +// AboutSetting is the input this is about. +func (e *NotAFileError) AboutSetting() string { return InputFile } + +// What happened. +func (e *NotAFileError) What() string { + if e.Directory { + return fmt.Sprintf("%s is a directory and this works out the checksum of one file", e.Path) + } + return fmt.Sprintf("%s is not a file but a device, a pipe or a socket", e.Path) +} + +// Why it is not read anyway. +func (e *NotAFileError) Why() string { + if e.Directory { + return "a directory has no bytes of its own to work a checksum out of" + } + return "reading one may never end, so it is not started" +} + +// Instead is what to name. +func (e *NotAFileError) Instead() string { + return "name one file" +} + +func (e *NotAFileError) Error() string { return tool.Sentence(e.What(), e.Why(), e.Instead()) } + +// Class says this is a mistake in the request: the path names something +// that is there, and it is the wrong kind of thing. +func (e *NotAFileError) Class() tool.Class { return tool.Asked } + +// ChangedError is a file that changed while it was being read. +type ChangedError struct { + Path string +} + +// AboutSetting is the input this is about. +func (e *ChangedError) AboutSetting() string { return InputFile } + +// What happened. +func (e *ChangedError) What() string { + return fmt.Sprintf("%s changed while it was being read", e.Path) +} + +// Why no checksum is given. +func (e *ChangedError) Why() string { + return "a checksum of a file being written is the checksum of no version of it" +} + +// Instead is when to try again. +func (e *ChangedError) Instead() string { + return "wait until whatever is writing it has finished, and run this again" +} + +func (e *ChangedError) Error() string { return tool.Sentence(e.What(), e.Why(), e.Instead()) } + +// Class says the request was fine and the file did not hold still. +func (e *ChangedError) Class() tool.Class { return tool.Reading } diff --git a/internal/tool/refusals.go b/internal/tool/refusals.go new file mode 100644 index 00000000..eb706a17 --- /dev/null +++ b/internal/tool/refusals.go @@ -0,0 +1,96 @@ +package tool + +import ( + "fmt" + "strings" +) + +// Sentence is a refusal of a tool as one line of the command line: what +// happened, why, and what to do instead - the shape "PNG cannot be smaller +// than 74 B - ... Ask for 74 B or more" already has. One function, so every +// tool refuses in the same shape without each writing it. +func Sentence(what, why, instead string) string { + return what + " - " + why + ". " + strings.ToUpper(instead[:1]) + instead[1:] +} + +// Class is what kind of mistake a refusal is, which is what the command line +// turns into an exit code (AR6: the code is a property of the error). Declared +// by the error rather than listed in the command line, so a tool added +// tomorrow refuses with the right code without anybody editing a list. +type Class int + +// The two kinds a tool refuses with. +const ( + // Asked is a request that cannot be run as written - a wrong setting, a + // missing file name, a directory where a file goes. + Asked Class = iota + 1 + // Reading is a request that was fine and a disk that did not cooperate. + Reading +) + +// Classified is a refusal that says what kind it is. +type Classified interface { + error + Class() Class +} + +// UnknownError is a tool this build does not have. +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 tool called %q", e.ID) +} + +// Why this is refused rather than guessed at. +func (e *UnknownError) Why() string { + return "a tool is named exactly, and the nearest name to a mistyped one may do something else" +} + +// Instead names what there is, from the registry rather than from a list. +func (e *UnknownError) Instead() string { + if len(e.Known) == 0 { + return "this build has no tools" + } + return "use one of: " + strings.Join(e.Known, ", ") +} + +func (e *UnknownError) Error() string { return Sentence(e.What(), e.Why(), e.Instead()) } + +// Class says this is a mistake in the request. +func (e *UnknownError) Class() Class { return Asked } + +// MissingInputError is a request without something the tool works on. +type MissingInputError struct { + Tool string + Input Input +} + +// AboutSetting is the input this is about, so a form puts the message under +// the box it belongs to - the same question the refusals of a setting answer. +func (e *MissingInputError) AboutSetting() string { return e.Input.Name } + +// What happened. Worded for both surfaces - a window shows this under the +// box and the command line prints it - so it names neither a flag nor a +// button. +func (e *MissingInputError) What() string { + return fmt.Sprintf("no %s was given", e.Input.Kind) +} + +// Why this is refused rather than started. +func (e *MissingInputError) Why() string { + return "the tool has nothing to read without one" +} + +// Instead is what to give it. +func (e *MissingInputError) Instead() string { + return fmt.Sprintf("name the %s to work on", e.Input.Kind) +} + +func (e *MissingInputError) Error() string { return Sentence(e.What(), e.Why(), e.Instead()) } + +// Class says this is a mistake in the request. +func (e *MissingInputError) Class() Class { return Asked } diff --git a/internal/tool/tool.go b/internal/tool/tool.go new file mode 100644 index 00000000..8d37bf15 --- /dev/null +++ b/internal/tool/tool.go @@ -0,0 +1,279 @@ +// Package tool is the registry of the tools on the Tools tab and behind +// "tfg tool": small things a person does with files they already have, beside +// the generator rather than inside it. +// +// A tool declares what it works on and what it takes, and both surfaces are +// drawn from that declaration - the command line registers a flag per setting +// and the window a field per setting, through the same code that already draws +// the settings of a format, a preset and a damage. So a tool added tomorrow +// reaches both without a line of surface code, which is D1 kept by +// construction rather than by somebody remembering. Several dozen of these are +// planned (docs/NARZEDZIA-SUMY-2026-09-29.md), which is what makes the +// declaration worth having from the first one. +// +// This package sits above the engine and the audit on purpose. Tools are +// facades - a set of samples is a run of the engine, a test of a memory stick +// is generate, verify and cleanup - so they have to be able to reach both. +// +// The work of a tool is done without a window and without a console. It gets +// its values checked and defaulted, a context to stop on and a place to report +// progress, and it hands back data. Wording the answer is the surface's job. +package tool + +import ( + "context" + "fmt" + "sort" + "sync" + + "github.com/donislawdev/TestingFilesGenerator/internal/format" +) + +// InputKind is what an input names on the disk. +type InputKind string + +// File is one existing file, read and never written. +const File InputKind = "file" + +// Input is what a tool works on, as opposed to how it works - a path, given on +// the command line without a flag in front of it and chosen in the window with +// a picker. +type Input struct { + // Name is the key of the input, in a request and in the words of every + // language. Not a flag: inputs are positional. + Name string + Kind InputKind + // Detail is the one sentence beside the field. + Detail string +} + +// Outcome is what a comparison came to. A tool that compares nothing leaves it +// Unasked. +type Outcome string + +// The three answers. Kept as data rather than a sentence, because the two +// surfaces word it themselves - the window in its own language - and a +// sentence composed here would be English in both. +const ( + Unasked Outcome = "none" + Match Outcome = "match" + Mismatch Outcome = "mismatch" +) + +// Verdict is the comparison a tool made, if it made one. +type Verdict struct { + Outcome Outcome + // About is what was compared - the name of an algorithm, for checksums. + // A key rather than a word, so it is never translated (G8). + About string + // Wanted is what the request said it should be, and Got what it is. Both + // are data and shown as they are, so a mismatch can be read without the + // table beside it. + Wanted string + Got string +} + +// Said is the verdict in the words of the command line, which are English +// (D9). Empty when nothing was compared. The window says the same in its own +// language, and a guard holds its English to this sentence. +func (v Verdict) Said() string { + switch v.Outcome { + case Match: + return fmt.Sprintf("Matches: the %s is %s, as expected.", v.About, v.Got) + case Mismatch: + return fmt.Sprintf("Does not match: the %s is %s and %s was expected.", v.About, v.Got, v.Wanted) + case Unasked: + } + return "" +} + +// Request is one run of a tool: the paths it works on and the settings it was +// given. Values left out take the declared default before the tool sees them. +type Request struct { + Inputs map[string]string + Values map[string]string +} + +// Result is what a run of a tool hands back. +type Result struct { + // Rows are the table a person reads, one entry per declared column. + // Values in it are data - digests, names, numbers - and are shown as they + // are in every language. + Rows [][]string + // Verdict is the comparison, when the request asked for one. + Verdict Verdict + // Data is the same answer typed, for --json. A script reading a digest + // asks for checksums.sha256 rather than for the second cell of a row. + Data any +} + +// Progress is told how much of the work is done, in bytes. The total is what +// the tool expects to read and may be zero when it does not know. +type Progress func(done, total int64) + +// Descriptor is one tool. +type Descriptor struct { + // ID is the name after "tfg tool". A public name under untouchable rule 10. + ID string + // Question is the title of the tool, asked the way a person asks it. + Question string + // Detail is one sentence on what the tool does. + Detail string + // Inputs are what it works on, in the order they are given. + Inputs []Input + // Settings are how it works, as the same declaration a format's settings + // are - so the window draws them with DeclaredFields and the command line + // describes them with Allowed. + Settings []format.Property + // Columns are the headings of the table in a result. + Columns []string + // Run does the work, on a request that has been checked and defaulted. + Run func(ctx context.Context, in Request, progress Progress) (Result, error) +} + +// reserved are the words after "tfg tool" that are operations rather than +// tools, so no tool can be called one of them. +var reserved = map[string]bool{"list": true, "show": true} + +var ( + mu sync.RWMutex + registry = map[string]Descriptor{} +) + +// Register adds a tool. It panics on a declaration that could not work, the +// way format.Register and damage.Register do: these are faults in the build, +// found by the first test that imports the package. +func Register(d Descriptor) { + mu.Lock() + defer mu.Unlock() + + if problem := unusable(d); problem != "" { + panic(fmt.Sprintf("tool: %q %s", d.ID, problem)) + } + if _, exists := registry[d.ID]; exists { + panic(fmt.Sprintf("tool: %q is registered twice", d.ID)) + } + for i := range d.Settings { + format.SortChoices(d.Settings[i].Choices) + } + registry[d.ID] = d +} + +// unusable is what is wrong with a declaration, or nothing. +func unusable(d Descriptor) string { + switch { + case d.ID == "": + return "has no id" + case reserved[d.ID]: + return "is an operation of tfg tool and cannot name a tool" + case d.Run == nil: + return "has nothing to run" + case d.Question == "" || d.Detail == "": + return "has no question or no sentence describing it" + case len(d.Columns) == 0: + return "declares no columns for its result" + } + return clashingName(d) +} + +// clashingName is a name declared twice across inputs and settings, which would +// make a request ambiguous and two flags one. +func clashingName(d Descriptor) string { + seen := map[string]bool{} + for _, in := range d.Inputs { + if seen[in.Name] { + return "declares " + in.Name + " twice" + } + seen[in.Name] = true + } + for _, p := range d.Settings { + if seen[p.Name] { + return "declares " + p.Name + " twice" + } + seen[p.Name] = true + } + return "" +} + +// Get is one tool by its id. +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 tool, by id. +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 the id of every tool, sorted. +func Names() []string { + mu.RLock() + defer mu.RUnlock() + return names() +} + +func names() []string { + out := make([]string, 0, len(registry)) + for id := range registry { + out = append(out, id) + } + sort.Strings(out) + return out +} + +// SettingNames is what a tool takes, in the declared order. +func (d Descriptor) SettingNames() []string { + out := make([]string, 0, len(d.Settings)) + for _, p := range d.Settings { + out = append(out, p.Name) + } + return out +} + +// CheckEach is every problem with a request, inputs first, in a stable order - +// all of them rather than the first, so a form can mark every box at once. +func (d Descriptor) CheckEach(in Request) []error { + var bad []error + for _, want := range d.Inputs { + if in.Inputs[want.Name] == "" { + bad = append(bad, &MissingInputError{Tool: d.ID, Input: want}) + } + } + return append(bad, format.CheckStated(d.ID, d.Settings, in.Values)...) +} + +// Start checks a request, fills in the defaults and runs the tool. +// +// The only way in. A surface that called Run itself would hand a tool a value +// nobody checked, and each tool would then have to repeat what the declaration +// already says. +func (d Descriptor) Start(ctx context.Context, in Request, progress Progress) (Result, error) { + if bad := d.CheckEach(in); len(bad) > 0 { + return Result{}, bad[0] + } + values := make(map[string]string, len(d.Settings)) + for _, p := range d.Settings { + values[p.Name] = p.Default + if given := in.Values[p.Name]; given != "" { + values[p.Name] = given + } + } + if progress == nil { + progress = func(int64, int64) {} + } + return d.Run(ctx, Request{Inputs: in.Inputs, Values: values}, progress) +} diff --git a/web/content/en/site.json b/web/content/en/site.json index fff7c95d..1e87bc0c 100644 --- a/web/content/en/site.json +++ b/web/content/en/site.json @@ -210,6 +210,7 @@ "preset": "build a set of files from a named test question", "formats": "list the formats this build supports", "damage": "list the ways this build can break a file on purpose", + "tool": "small things to do with files you already have", "version": "print the tool version", "license": "print the licence and what it means for generated files" }, diff --git a/web/content/pl/site.json b/web/content/pl/site.json index 5b2eb147..fbfaebd4 100644 --- a/web/content/pl/site.json +++ b/web/content/pl/site.json @@ -210,6 +210,7 @@ "preset": "buduje zestaw plików z nazwanego pytania testowego", "formats": "wypisuje formaty, które ta wersja obsługuje", "damage": "wypisuje sposoby, którymi ta wersja umie celowo zepsuć plik", + "tool": "drobne czynności na plikach, które już masz", "version": "wypisuje wersję narzędzia", "license": "wypisuje licencję i to, co znaczy dla wygenerowanych plików" }, diff --git a/web/public/docs/index.html b/web/public/docs/index.html index 1484898a..4b661efc 100644 --- a/web/public/docs/index.html +++ b/web/public/docs/index.html @@ -93,6 +93,7 @@

What commands are there?

tfg preset build a set of files from a named test question tfg formats list the formats this build supports tfg damage list the ways this build can break a file on purpose +tfg tool small things to do with files you already have tfg version print the tool version tfg license print the licence and what it means for generated files diff --git a/web/public/pl/dokumentacja/index.html b/web/public/pl/dokumentacja/index.html index a406e81a..2dce6de2 100644 --- a/web/public/pl/dokumentacja/index.html +++ b/web/public/pl/dokumentacja/index.html @@ -93,6 +93,7 @@

Jakie są komendy?

tfg preset buduje zestaw plików z nazwanego pytania testowego tfg formats wypisuje formaty, które ta wersja obsługuje tfg damage wypisuje sposoby, którymi ta wersja umie celowo zepsuć plik +tfg tool drobne czynności na plikach, które już masz tfg version wypisuje wersję narzędzia tfg license wypisuje licencję i to, co znaczy dla wygenerowanych plików