| 1 | package main |
| 2 | |
| 3 | import ( |
| 4 | "encoding/json" |
| 5 | "os" |
| 6 | "path/filepath" |
| 7 | "sync" |
| 8 | "testing" |
| 9 | ) |
| 10 | |
| 11 | func TestValidateWindowStateAcceptsWindowsBorderOrigin(t *testing.T) { |
| 12 | state := DesktopWindowState{Width: 1240, Height: 720, X: -8, Y: -8, Maximised: false} |
| 13 | if err := validateWindowState(state); err != nil { |
| 14 | t.Fatalf("x=-8,y=-8 must be accepted: %v", err) |
| 15 | } |
| 16 | } |
| 17 | |
| 18 | func TestValidateWindowStateRejectsIllegalSizes(t *testing.T) { |
| 19 | cases := []DesktopWindowState{ |
| 20 | {Width: 0, Height: 720, X: 10, Y: 10}, |
| 21 | {Width: 100, Height: 720, X: 10, Y: 10}, |
| 22 | {Width: 1240, Height: 100, X: 10, Y: 10}, |
| 23 | {Width: maxWindowDimension + 1, Height: 720, X: 10, Y: 10}, |
| 24 | {Width: 1240, Height: 720, X: minWindowOrigin - 1, Y: 10}, |
| 25 | {Width: 1240, Height: 720, X: 10, Y: maxWindowOriginAbs + 1}, |
| 26 | } |
| 27 | for _, c := range cases { |
| 28 | if err := validateWindowState(c); err == nil { |
| 29 | t.Fatalf("expected rejection for %+v", c) |
| 30 | } |
| 31 | } |
| 32 | } |
| 33 | |
| 34 | func TestParseWindowStateJSONCorruptPayload(t *testing.T) { |
| 35 | if _, err := parseWindowStateJSON([]byte(`{not-json`)); err == nil { |
| 36 | t.Fatal("corrupt JSON must fail") |
| 37 | } |
| 38 | if _, err := parseWindowStateJSON([]byte(`{"width":50,"height":50,"x":0,"y":0}`)); err == nil { |
| 39 | t.Fatal("undersized geometry must fail") |
| 40 | } |
| 41 | } |
| 42 | |
| 43 | func TestWindowStateRoundTripNegativeDisplayOrigin(t *testing.T) { |
| 44 | seedWindowStateFile(t, DesktopWindowState{Width: 1100, Height: 700, X: -1800, Y: -900, Maximised: true}) |
| 45 | saved, ok := loadWindowState() |
| 46 | if !ok { |
| 47 | t.Fatal("negative monitor origin rejected") |
| 48 | } |
| 49 | app := NewApp() |
| 50 | if err := app.SaveWindowState(saved); err != nil { |
| 51 | t.Fatal(err) |
| 52 | } |
| 53 | restored, ok := loadWindowState() |
| 54 | if !ok || restored != saved { |
| 55 | t.Fatalf("round trip: %+v", restored) |
| 56 | } |
| 57 | } |
| 58 | |
| 59 | func TestSaveWindowStatePersistsAndSeedsLastKnown(t *testing.T) { |
| 60 | isolateDesktopUserDirs(t) |
| 61 | resetLastKnownWindowStateForTest() |
| 62 | t.Cleanup(resetLastKnownWindowStateForTest) |
| 63 | |
| 64 | app := NewApp() |
| 65 | want := DesktopWindowState{Width: 1100, Height: 700, X: -8, Y: -8, Maximised: true} |
| 66 | if err := app.SaveWindowState(want); err != nil { |
| 67 | t.Fatalf("SaveWindowState: %v", err) |
| 68 | } |
| 69 | |
| 70 | got, ok := loadWindowState() |
| 71 | if !ok { |
| 72 | t.Fatal("loadWindowState failed after save") |
| 73 | } |
| 74 | if got != want { |
| 75 | t.Fatalf("loaded state = %+v, want %+v", got, want) |
| 76 | } |
| 77 | last, ok := lastKnownWindowState() |
| 78 | if !ok || last != want { |
| 79 | t.Fatalf("last known = %+v ok=%v, want %+v", last, ok, want) |
| 80 | } |
| 81 | } |
| 82 | |
| 83 | func TestSaveWindowStateRejectsInvalidWithoutMutatingLastKnown(t *testing.T) { |
| 84 | isolateDesktopUserDirs(t) |
| 85 | resetLastKnownWindowStateForTest() |
| 86 | t.Cleanup(resetLastKnownWindowStateForTest) |
| 87 | |
| 88 | app := NewApp() |
| 89 | good := DesktopWindowState{Width: 1000, Height: 600, X: 12, Y: 24, Maximised: false} |
| 90 | if err := app.SaveWindowState(good); err != nil { |
| 91 | t.Fatal(err) |
| 92 | } |
| 93 | if err := app.SaveWindowState(DesktopWindowState{Width: 10, Height: 10, X: 0, Y: 0}); err == nil { |
| 94 | t.Fatal("expected invalid size rejection") |
| 95 | } |
| 96 | last, ok := lastKnownWindowState() |
| 97 | if !ok || last != good { |
| 98 | t.Fatalf("last known mutated to %+v ok=%v", last, ok) |
| 99 | } |
| 100 | // Disk must still hold the last valid report. |
| 101 | got, ok := loadWindowState() |
| 102 | if !ok || got != good { |
| 103 | t.Fatalf("disk state = %+v ok=%v, want %+v", got, ok, good) |
| 104 | } |
| 105 | } |
| 106 | |
| 107 | func TestSaveWindowStateSyncUsesLastKnownWithoutNativeQuery(t *testing.T) { |
| 108 | // Regression: shutdown/beforeClose must not call WindowGetSize/Position/ |
| 109 | // IsMaximised. saveWindowStateSync only re-writes the last frontend report. |
| 110 | isolateDesktopUserDirs(t) |
| 111 | resetLastKnownWindowStateForTest() |
| 112 | t.Cleanup(resetLastKnownWindowStateForTest) |
| 113 | |
| 114 | app := NewApp() |
| 115 | // Simulate a frontend report that was accepted in-memory but not yet on disk |
| 116 | // (e.g. crash between remember and write, or deleted file). |
| 117 | want := DesktopWindowState{Width: 1280, Height: 800, X: 40, Y: 50, Maximised: false} |
| 118 | rememberWindowState(want) |
| 119 | _ = os.Remove(windowStatePath()) |
| 120 | |
| 121 | app.saveWindowStateSync() |
| 122 | |
| 123 | raw, err := os.ReadFile(windowStatePath()) |
| 124 | if err != nil { |
| 125 | t.Fatalf("expected re-persist: %v", err) |
| 126 | } |
| 127 | var got DesktopWindowState |
| 128 | if err := json.Unmarshal(raw, &got); err != nil { |
| 129 | t.Fatal(err) |
| 130 | } |
| 131 | if got != want { |
| 132 | t.Fatalf("re-persisted = %+v, want %+v", got, want) |
| 133 | } |
| 134 | } |
| 135 | |
| 136 | func TestSaveWindowStateSyncNoopWithoutLastKnown(t *testing.T) { |
| 137 | isolateDesktopUserDirs(t) |
| 138 | resetLastKnownWindowStateForTest() |
| 139 | t.Cleanup(resetLastKnownWindowStateForTest) |
| 140 | |
| 141 | app := NewApp() |
| 142 | app.saveWindowStateSync() // must not panic or create a file |
| 143 | if _, err := os.Stat(windowStatePath()); !os.IsNotExist(err) { |
| 144 | t.Fatalf("expected no file, err=%v", err) |
| 145 | } |
| 146 | } |
| 147 | |
| 148 | func TestLoadWindowStateCorruptJSON(t *testing.T) { |
| 149 | isolateDesktopUserDirs(t) |
| 150 | resetLastKnownWindowStateForTest() |
| 151 | t.Cleanup(resetLastKnownWindowStateForTest) |
| 152 | |
| 153 | path := windowStatePath() |
| 154 | if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { |
| 155 | t.Fatal(err) |
| 156 | } |
| 157 | if err := os.WriteFile(path, []byte("{broken"), 0o644); err != nil { |
| 158 | t.Fatal(err) |
| 159 | } |
| 160 | if _, ok := loadWindowState(); ok { |
| 161 | t.Fatal("corrupt JSON must not restore") |
| 162 | } |
| 163 | } |
| 164 | |
| 165 | func TestBackgroundHideUsesLastKnownMaximised(t *testing.T) { |
| 166 | isolateDesktopUserDirs(t) |
| 167 | resetLastKnownWindowStateForTest() |
| 168 | t.Cleanup(resetLastKnownWindowStateForTest) |
| 169 | |
| 170 | app := NewApp() |
| 171 | if app.lastKnownMaximised() { |
| 172 | t.Fatal("default maximised should be false") |
| 173 | } |
| 174 | if err := app.SaveWindowState(DesktopWindowState{Width: 900, Height: 600, X: 0, Y: 0, Maximised: true}); err != nil { |
| 175 | t.Fatal(err) |
| 176 | } |
| 177 | if !app.lastKnownMaximised() { |
| 178 | t.Fatal("expected maximised from last report") |
| 179 | } |
| 180 | } |
| 181 | |
| 182 | func TestSaveWindowStateConcurrentReports(t *testing.T) { |
| 183 | // Shutdown race: concurrent frontend polls + saveWindowStateSync must not |
| 184 | // panic or leave invalid JSON. |
| 185 | isolateDesktopUserDirs(t) |
| 186 | resetLastKnownWindowStateForTest() |
| 187 | t.Cleanup(resetLastKnownWindowStateForTest) |
| 188 | |
| 189 | app := NewApp() |
| 190 | var wg sync.WaitGroup |
| 191 | for i := range 32 { |
| 192 | wg.Add(1) |
| 193 | go func(i int) { |
| 194 | defer wg.Done() |
| 195 | state := DesktopWindowState{ |
| 196 | Width: 800 + i, |
| 197 | Height: 600 + i, |
| 198 | X: -8 + i%3, |
| 199 | Y: -8 + i%5, |
| 200 | } |
| 201 | _ = app.SaveWindowState(state) |
| 202 | app.saveWindowStateSync() |
| 203 | }(i) |
| 204 | } |
| 205 | wg.Wait() |
| 206 | |
| 207 | got, ok := loadWindowState() |
| 208 | if !ok { |
| 209 | t.Fatal("final load failed after concurrent writes") |
| 210 | } |
| 211 | if err := validateWindowState(got); err != nil { |
| 212 | t.Fatalf("final state invalid: %v (%+v)", err, got) |
| 213 | } |
| 214 | } |
| 215 | |
| 216 | func TestSaveWindowStateDPIZeroShutdownPathNeverQueriesNative(t *testing.T) { |
| 217 | // Contract test: saveWindowStateSync must succeed with ctx nil (no shell |
| 218 | // runtime) and never require native DPI/window APIs. This is the regression |
| 219 | // guard for ScaleToDefaultDPI panics during Windows shutdown. |
| 220 | isolateDesktopUserDirs(t) |
| 221 | resetLastKnownWindowStateForTest() |
| 222 | t.Cleanup(resetLastKnownWindowStateForTest) |
| 223 | |
| 224 | app := NewApp() |
| 225 | app.ctx = nil |
| 226 | if err := app.SaveWindowState(DesktopWindowState{Width: 1024, Height: 768, X: -8, Y: -8}); err != nil { |
| 227 | t.Fatal(err) |
| 228 | } |
| 229 | // Wipe disk and re-persist from memory only. |
| 230 | _ = os.Remove(windowStatePath()) |
| 231 | app.saveWindowStateSync() |
| 232 | got, ok := loadWindowState() |
| 233 | if !ok { |
| 234 | t.Fatal("expected re-persisted state without native queries") |
| 235 | } |
| 236 | if got.X != -8 || got.Y != -8 { |
| 237 | t.Fatalf("border origin lost: %+v", got) |
| 238 | } |
| 239 | } |
| 240 |