Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -1060,7 +1060,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 internal/tool/tool.go internal/gui/window/tools.go internal/format/video/ahead.go go.mod .github/workflows/ci.yml .github/build-tags .github/race-weights .github/scripts/race_parts.py'
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 internal/gui/window/work.go internal/gui/window/closing.go internal/format/video/ahead.go go.mod .github/workflows/ci.yml .github/build-tags .github/race-weights .github/scripts/race_parts.py'
# 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
Expand Down
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,14 @@ because it turns other people's test suites red.

### Fixed

- **Cancel answers at once, and closing the window during a run no longer
freezes it.** Both used to wait for the write in progress with the window
standing still, which on a slow or network disk could last long enough for
Windows to offer to end the program in the middle of a file. Cancel now
switches itself off and says the run is stopping until it has. Closing the
window shows that it closes once the work has stopped, and then it does.
Nothing is left half written either way, and the manifest still describes
what was.
- **A run of many files no longer runs out of memory while it saves its
manifest.** The manifest was put together in memory in one piece, about
ten times its own size, after the last file. In a container with 1 GB a
Expand Down
3 changes: 3 additions & 0 deletions internal/guard/alwaysvisible_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,10 @@ func TestAddingABatchIsOutOfUseWhileARunIsGoing(t *testing.T) {
if cancel == nil {
t.Fatalf("there is no Cancel button. The screen has: %v", buttonNames(body))
}
// Cancel asks the run to stop and does not wait for it, so the run is
// waited for here - the form comes back with the run's last word (R06).
cancel.OnTapped()
screen.Settled()

if add.Disabled() {
t.Error("the add button is still out of use after the run stopped, so it never comes back.")
Expand Down
3 changes: 3 additions & 0 deletions internal/guard/codeshape_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,9 @@ const (
// Lowered from 399 on 2026-10-07: what makes a container moved out of
// format/format.go into container.go when two fields for eml took it
// past the ceiling. The longest file is gui/window/recipe.go now.
// Held at 395 on 2026-11-03: the list of batches moved out of
// gui/window/recipe.go into recipelist.go when one import took it past
// the ceiling. The longest file is oracle/oracle.go now.
longestFile = 395

// Depth answers a different question than length, and it is the better
Expand Down
13 changes: 13 additions & 0 deletions internal/guard/concurrency_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,19 @@ var mayBeConcurrent = map[string]string{
// invariant G7 exists to hold. Added 2026-08-05 with the first generate
// window, and the owner was told.
"internal/gui/window/run.go": "the run happens beside the window, and closing the window waits for it",
// The handle on one piece of work beside the window, out of run.go and
// tools.go on 2026-11-03 when Cancel stopped waiting (O262, the owner's
// decision that day). It holds the first lock in the window, and the lock
// is for the test driver: there fyne.Do runs the worker's progress on the
// worker, so a press of Cancel and a line of progress are two goroutines
// writing one status line. In the shipped program both are on the
// interface thread and the lock is never contended.
"internal/gui/window/work.go": "Cancel and the worker's news say things to one screen, ordered by one lock",
// Closing the window during work waits for it on a goroutine of its own,
// so the window goes on drawing while the disk finishes the write in hand
// - the owner's decision of 2026-11-03 (O262). Waiting on the interface
// thread was a window Windows offers to end, in the middle of a file.
"internal/gui/window/closing.go": "closing the window waits for the work beside the window, not on its thread",
// The window's clock. time.AfterFunc calls back on a goroutine of its
// own, which is how a delayed piece of work - the busy face, the wait for
// quiet - waits without holding the window, and the callback hands its
Expand Down
4 changes: 1 addition & 3 deletions internal/guard/contextroots_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,9 +27,7 @@ import (
// code inside a function does not make the list lie (pitfall 11).
var contextRoots = map[string]string{
"cmd/tfg/main.go main": "the process's own context, the one the signal cancels",
"internal/gui/window/run.go runner.onPreview": "a preview starts here, and Cancel on the screen is what stops it",
"internal/gui/window/run.go runner.startRun": "a run started in the window begins here, and Cancel is what stops it",
"internal/gui/window/tools.go startTool": "a tool started in the window begins here, and its Cancel is what stops it",
"internal/gui/window/work.go begin": "a preview, a run or a tool started in the window begins here, and Cancel or closing the window is what stops it",
"internal/oracle/oracle.go Checker.Check": "the oracle runs somebody else's reader under a time limit of its own - a measuring instrument for the guards, never on the path of a run",
"internal/oracle/oracle.go PictureChanges": "the same instrument, a reference reader under a time limit",
"internal/oracle/oracle.go Strict": "the same instrument, a reference reader under a time limit",
Expand Down
121 changes: 121 additions & 0 deletions internal/guard/damagewindow_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,11 @@ import (
"strings"
"testing"

"fyne.io/fyne/v2"

"github.com/donislawdev/TestingFilesGenerator/internal/damage"
"github.com/donislawdev/TestingFilesGenerator/internal/engine"
"github.com/donislawdev/TestingFilesGenerator/internal/format"
"github.com/donislawdev/TestingFilesGenerator/internal/gui/parts"
"github.com/donislawdev/TestingFilesGenerator/internal/gui/text"
"github.com/donislawdev/TestingFilesGenerator/internal/gui/window"
Expand Down Expand Up @@ -87,6 +90,124 @@ func TestTheWindowDrawsAFieldForEveryDamageParameter(t *testing.T) {
t.Logf("%d damage parameter(s) drawn from the registry", checked)
}

// Choosing a damage again, or a format, leaves the registry of fields holding
// what the screen draws and nothing else.
//
// The registry is what a refusal is addressed against and what a run freezes,
// so a field the screen no longer draws is a place a refusal could still go,
// under a box nobody can see. Every choice of a damage used to leave one
// behind - its parameters were added and never taken away - so the count is
// read after many choices rather than one: one stale field reads like the cost
// of the screen, twenty read like what they are (R18 of the stability review).
// Then the same with the format changed in between, because the parameters sit
// after the format's fields, and a mark that did not move with the format
// would cut the format's fields instead of the damage's.
func TestChoosingADamageAgainLeavesNoFieldBehind(t *testing.T) {
host := newFakeHost(t)
gen := window.NewGenerate(host)
content := gen.Object()
t.Cleanup(func() { join(host) })
fields := gen.Fields()
picker, ok := controlUnder(content, text.FieldDamage()).(*parts.Chooser)
if !ok {
t.Fatal("the damage field is not a list to choose from")
}
var chosen damage.Descriptor
for _, d := range damage.All() {
if len(d.Parameters) > 0 {
chosen = d
break
}
}
if chosen.ID == "" {
t.Fatal("no damage declares a parameter, so this proved nothing")
}

// The formats that declare the fewest and the most settings, asked of the
// registry: two formats with as many fields each would pass a mark that
// stayed where the first one left it.
formats := chooserIn(t, fields, "format")
declared := map[string]int{}
var few, many string
for _, id := range formats.Options {
d, err := format.Get(id)
if err != nil {
t.Fatalf("the format menu offers %q: %v", id, err)
}
declared[id] = len(d.Properties)
if few == "" || declared[id] < declared[few] {
few = id
}
if many == "" || declared[id] > declared[many] {
many = id
}
}
if declared[few] == declared[many] {
t.Fatalf("every format declares %d settings, so a mark that stayed with the first would pass", declared[few])
}

counts := map[string]int{}
for _, f := range []string{few, many} {
// None before the format rather than after it: a change of format
// takes every parameter away, so this is a count no stale field can
// be part of, whatever the screen does with a choice of none.
picker.SetSelected(text.DamageNone())
formats.SetSelected(f)
without := fields.Len()
picker.SetSelected(chosen.ID)
counts[f] = fields.Len()
if counts[f] <= without {
t.Fatalf("choosing %s with %s added no field (%d before and after), so this proved nothing", chosen.ID, f, without)
}
}
for i := 0; i < 20; i++ {
picker.SetSelected(text.DamageNone())
picker.SetSelected(chosen.ID)
}
if got := fields.Len(); got != counts[many] {
t.Errorf("after twenty more choices of %s the registry holds %d fields, and %d after the first", chosen.ID, got, counts[many])
}
for i := 0; i < 20; i++ {
formats.SetSelected([]string{few, many}[i%2])
picker.SetSelected(text.DamageNone())
picker.SetSelected(chosen.ID)
}
if got := fields.Len(); got != counts[many] {
t.Errorf("after twenty changes of format the registry holds %d fields, and %d after the first", got, counts[many])
}
drawn := map[fyne.CanvasObject]bool{}
walk(content, func(o fyne.CanvasObject) { drawn[o] = true })
for i, control := range fields.Controls() {
if !drawn[control] {
t.Errorf("the registry holds a field for %q the screen does not draw", fields.All()[i].Setting)
}
}
// And the other way round, for what the two choices declare: a box on the
// screen with no field behind it is a box whose refusal has nowhere to go,
// which is what a mark that cut the format's fields would leave.
d, err := format.Get(many)
if err != nil {
t.Fatal(err)
}
for _, name := range settingNames(d.Properties, chosen.Parameters) {
field := fields.Lookup(name)
if field == nil || !drawn[field.Control] {
t.Errorf("%q of %s with %s is drawn without a field in the registry behind it", name, many, chosen.ID)
}
}
}

// settingNames is the name of every setting in the lists given, in order.
func settingNames(lists ...[]format.Property) []string {
var out []string
for _, list := range lists {
for _, p := range list {
out = append(out, p.Name)
}
}
return out
}

// A run started from the window is damaged, and the bytes say so.
//
// Pressed rather than settled, because settle is the window's own and a guard
Expand Down
84 changes: 37 additions & 47 deletions internal/guard/generatewindow_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@ import (
"sort"
"strings"
"testing"
"time"

"fyne.io/fyne/v2"

Expand Down Expand Up @@ -454,28 +453,38 @@ func TestGeneratingFromTheWindowWritesTheFilesAndTheManifest(t *testing.T) {
// and closing straight away ends the process while the engine is still winding
// down, so the manifest describing what did get written never lands - and that
// manifest is the only thing cleanup can work from.
//
// The wait happens off the interface thread since 2026-11-03 (R06), so the
// closing is joined here before the disk is read. That the window is let go
// only once the run has stopped is held by
// TestClosingDuringARunKeepsTheWindowDrawingUntilTheRunHasStopped, which can
// see the moment in between.
//
// The writers are held until the run is asked to stop, so the window is
// closed in the middle of the run rather than at whatever point a run of
// four hundred files has reached by then - see writersHeld.
func TestClosingTheWindowDuringARunStopsItAndWaitsForIt(t *testing.T) {
dir := t.TempDir()
host, content := screen(t)
host, content, held := writersHeldScreen(t)

fill(t, content, text.FieldOutputDir(), dir)
fill(t, content, text.FieldSize(), "64kb")
fill(t, content, text.FieldCount(), "400")
press(t, content, "Generate")
waitUntilTheRunHasStarted(t, dir)
held.inTheMiddle(t)

if host.intercept == nil {
t.Fatal("the window has no close intercept, so closing it does not reach the run at all")
}
host.intercept()
join(host)
held.stoppedByTheGuard(t)

if host.closed != 1 {
t.Errorf("the window was closed %d time(s) and it should be closed exactly once", host.closed)
}

// The wait is what makes this assertable at all. If closing did not wait,
// the engine would still be writing right now and this count would be a
// number that changes while it is read.
// Joined, so this count is not a number that changes while it is read.
names := namesIn(t, dir)
for _, name := range names {
if strings.Contains(name, core.PartialMarker) {
Expand All @@ -489,6 +498,11 @@ func TestClosingTheWindowDuringARunStopsItAndWaitsForIt(t *testing.T) {
if info.Size() == 0 {
t.Error("the manifest is the empty file the run claimed, so closing the window did not wait for it to be written")
}
// Stopped rather than waited out: a close that let all four hundred
// through would be a window that cannot be closed during a run.
if raw, err := os.ReadFile(filepath.Join(dir, "manifest.json")); err != nil || !strings.Contains(string(raw), `"complete": false`) {
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
t.Errorf("the manifest does not say the run was stopped, so closing the window did not reach it (%v)", err)
}
t.Logf("closing the window stopped a 400 file run with %d file(s) on disk and a manifest", len(names)-1)
}

Expand Down Expand Up @@ -576,31 +590,36 @@ func TestWhatIsTypedOnTheScreenIsWhatGetsWritten(t *testing.T) {
// brief, and every other guard here was green. A control nobody presses is a
// control nobody has checked.
//
// Deterministic without polling, because stopping waits. By the time the press
// returns the engine has wound down, so what is on the disk is not a number
// that changes while it is read.
// Deterministic without polling, because the run is joined after the press.
// Cancel itself does not wait since 2026-11-03 (R06) - that is held by
// TestCancelAnswersAtOnceAndSaysWhatItWaitsFor - so what is on the disk is
// read once the engine has wound down, not while it changes. The writers are
// held until the run is asked to stop, so Cancel is pressed in the middle of
// the run - see writersHeld.
func TestCancelStopsTheRun(t *testing.T) {
dir := t.TempDir()
_, content := screen(t)
host, content, held := writersHeldScreen(t)

fill(t, content, text.FieldOutputDir(), dir)
fill(t, content, text.FieldSize(), "64kb")
fill(t, content, text.FieldCount(), "400")
press(t, content, "Generate")
waitUntilTheRunHasStarted(t, dir)
held.inTheMiddle(t)

cancel := buttonNamed(content, "Cancel")
if cancel == nil {
t.Fatalf("there is no Cancel button. The screen has: %v", buttonNames(content))
}
cancel.OnTapped()
join(host)
held.stoppedByTheGuard(t)

info, err := os.Stat(filepath.Join(dir, "manifest.json"))
if err != nil {
t.Fatalf("no manifest after cancelling: %v", err)
}
if info.Size() == 0 {
t.Fatal("the manifest is the empty file the run claimed, so cancelling did not wait for the run to wind down")
t.Fatal("the manifest is the empty file the run claimed, so the run that was cancelled never wrote down what it did")
}

// Stopped rather than finished. A cancel that let all four hundred through
Expand Down Expand Up @@ -750,47 +769,18 @@ func TestARunIsStoppableFromTheMomentItStarts(t *testing.T) {
fn = fn[:end]
}

handle := strings.Index(fn, "r.stop = func()")
// The handle is the screen's work since 2026-11-03 (work.go), which took
// the place of r.stop.
handle := strings.Index(fn, "r.work = w")
worker := strings.Index(fn, "go func()")
switch {
case handle < 0:
t.Fatal("startRun no longer assigns r.stop, so nothing would stop a run at all")
t.Fatal("startRun no longer assigns r.work, so nothing would stop a run at all")
case worker < 0:
t.Fatal("startRun no longer starts a goroutine, so this guard is reading the wrong function")
case handle > worker:
t.Error("startRun starts the worker before it assigns r.stop.\n" +
t.Error("startRun starts the worker before it assigns r.work.\n" +
"Reason: between those two statements files are being written and Stop has nothing to call, so closing the window there ends the process inside a file with no manifest.\n" +
"What to do: assign r.stop first. ctx, cancel and done all exist by then, so nothing is gained by waiting.")
}
}

// waitUntilTheRunHasStarted blocks until the run has put something on the disk.
//
// A guard about interrupting a run has to interrupt a run, and since 2026-08-26
// pressing Generate no longer means one is going: planning moved off the
// interface thread, so for a moment after the press the worker is still working
// out what to write. Cancelling in that moment is answered correctly and
// uselessly - nothing has been written, so there is nothing to record and no
// manifest, which is the rule Result.Started states.
//
// The manifest claims its name before the first file, so anything at all
// appearing here means the writing has begun.
//
// This one polls a budget on purpose, and it is the ONLY wait here that still
// should. Its siblings stopped: waiting for a manifest is now a join, because
// the worker writes the record before it closes its channel, and the budget
// there was bounding the whole run rather than the wait for a filesystem -
// four failures came of that, O111 and O126. Joining cannot work here, because
// this waits for a run to START so that it can be interrupted, and a join by
// definition returns when the work is over.
func waitUntilTheRunHasStarted(t *testing.T, dir string) {
t.Helper()
deadline := time.Now().Add(20 * time.Second)
for time.Now().Before(deadline) {
if entries, err := os.ReadDir(dir); err == nil && len(entries) > 0 {
return
}
time.Sleep(time.Millisecond)
"What to do: assign r.work first. The work exists by then, so nothing is gained by waiting.")
}
t.Fatal("nothing was written within twenty seconds, so there was never a run to interrupt")
}
1 change: 1 addition & 0 deletions internal/guard/guitext_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,7 @@ var notWords = map[string]string{
`"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",
`"closing"`: "what the keyboard acts on while the window waits to close - no screen, so no key does anything - never shown",
`"file"`: "the scheme of a local address, which is a protocol name rather than prose",
`"."`: "the working directory, when the system will not say which one it is",
`"tfg-out"`: "the folder the window offers to write into. A directory name rather than " +
Expand Down
Loading
Loading