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