Repository navigation
Expand file tree
/
Copy pathmain_test.go
More file actions
275 lines (250 loc) · 8.73 KB
/
Copy pathmain_test.go
File metadata and controls
275 lines (250 loc) · 8.73 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
package main
import (
"errors"
"sync"
"sync/atomic"
"testing"
"time"
"unidoc-pdf-debugger/internal/pdfcore"
"unidoc-pdf-debugger/internal/pendingopen"
)
// slowOpener is a pdfOpener stub that blocks inside OpenFile for sleepFor.
// Used by TestOpenFileAndEmitReturnsBeforeParseCompletes to verify
// openFileAndEmitWithWarning returns BEFORE the parse completes (50ms
// wallclock budget while the goroutine sleeps for 2s).
type slowOpener struct {
sleepFor time.Duration
openCalled atomic.Bool
openReached chan struct{}
}
func (s *slowOpener) OpenFile(path string) (*pdfcore.DocumentInfo, error) {
s.openCalled.Store(true)
close(s.openReached)
time.Sleep(s.sleepFor)
// Return a sentinel error so the dispatched goroutine exits cleanly
// after the sleep without trying to use a nil DocumentInfo.
return nil, errors.New("slowOpener: sentinel")
}
func (s *slowOpener) GetTreeRoot(tabID string) (*pdfcore.TreeNode, error) {
return nil, errors.New("slowOpener: GetTreeRoot not expected")
}
func (s *slowOpener) GetChildren(tabID string, nodeID string) ([]*pdfcore.TreeNode, error) {
return nil, errors.New("slowOpener: GetChildren not expected")
}
func (s *slowOpener) CloseDocument(tabID string) error {
return nil
}
// recordingEmitter is an eventEmitter stub that captures emitted events.
// Used to assert document:load-start fires synchronously before
// openFileAndEmitWithWarning returns, while document:opened/document:error
// fires only from the goroutine after the parse completes.
type recordingEmitter struct {
mu sync.Mutex
events []string
}
func (r *recordingEmitter) Emit(name string, data ...any) bool {
r.mu.Lock()
defer r.mu.Unlock()
r.events = append(r.events, name)
return true
}
func (r *recordingEmitter) snapshot() []string {
r.mu.Lock()
defer r.mu.Unlock()
out := make([]string, len(r.events))
copy(out, r.events)
return out
}
// TestOpenFileAndEmitReturnsBeforeParseCompletes asserts
// openFileAndEmitWithWarning returns within 50ms while the parse is still in
// flight (svc.OpenFile sleeps for 2s here): it dispatches the pdfcpu read to a
// goroutine so the Wails event-dispatch goroutine stays free to service window
// resize and menu clicks. The 50ms ceiling is a wallclock budget covering
// race-detector overhead, GC pause and synchronous event-emit dispatch.
//
// Driven through a slow-OpenFile seam (pdfOpener) and a recording emitter
// (eventEmitter); *pdfservice.PDFService and *application.EventManager satisfy
// both interfaces implicitly.
func TestOpenFileAndEmitReturnsBeforeParseCompletes(t *testing.T) {
const parseDuration = 2 * time.Second
const latencyBudget = 50 * time.Millisecond
opener := &slowOpener{
sleepFor: parseDuration,
openReached: make(chan struct{}),
}
emitter := &recordingEmitter{}
var wg sync.WaitGroup
wg.Add(1)
start := time.Now()
openFileAndEmitWithWarning(opener, emitter, "/fake/path.pdf", "", &wg)
elapsed := time.Since(start)
// Contract: return-time-versus-parse-time. The function MUST
// return well before the 2s parse completes.
if elapsed > latencyBudget {
t.Errorf("openFileAndEmitWithWarning returned in %v, exceeds %v budget (returns within 50ms while parse is in flight). flake-handling note: if CI flakes above 50ms, raise to 200ms; never to 0.", elapsed, latencyBudget)
}
// The dispatched goroutine MUST be in flight (OpenFile entered) before
// the function returns. Without this assertion, a buggy implementation
// that synchronously skipped the OpenFile call entirely would also
// pass the latency check.
select {
case <-opener.openReached:
// goroutine reached OpenFile and is now sleeping.
case <-time.After(latencyBudget):
t.Fatalf("openFileAndEmitWithWarning returned but the dispatched goroutine did not reach OpenFile within %v -- contract violated (parse should already be in flight)", latencyBudget)
}
// document:load-start MUST be emitted synchronously (before return) so
// the frontend renders the loading indicator without waiting on the
// goroutine scheduler. This is the spec's "Emit document:load-start
// synchronously (already does)" contract from the Decision section.
events := emitter.snapshot()
if len(events) == 0 || events[0] != "document:load-start" {
t.Errorf("expected document:load-start to be emitted synchronously before return; got events=%v", events)
}
// document:opened / document:error MUST NOT yet be emitted -- the
// goroutine is still sleeping inside OpenFile. If either fires before
// the parse completes, the dispatch shape is broken.
for _, e := range events[1:] {
if e == "document:opened" || e == "document:error" {
t.Errorf("event %q fired before parse completed -- the pdfcpu read is not actually dispatched to a goroutine", e)
}
}
// Wait for the goroutine to complete so the test does not leak it.
// The slowOpener returns an error after the sleep, which routes to
// document:error and then wg.Done(). Drain the full parseDuration plus
// a small grace window.
done := make(chan struct{})
go func() {
wg.Wait()
close(done)
}()
select {
case <-done:
// goroutine completed cleanly.
case <-time.After(parseDuration + 500*time.Millisecond):
t.Fatalf("dispatched goroutine did not complete within %v -- wg.Done() not reached", parseDuration+500*time.Millisecond)
}
if !opener.openCalled.Load() {
t.Errorf("slowOpener.OpenFile was never called -- the dispatched goroutine did not run")
}
}
// extractPDFPaths extracts .pdf paths from args.
//
// Given a second instance is launched with args containing PDF paths, When
// extractPDFPaths parses the args, Then it returns only the .pdf arguments
// (case-insensitive extension).
func TestExtractPDFPaths(t *testing.T) {
tests := []struct {
name string
args []string
want []string
}{
{
name: "single pdf arg",
args: []string{"unidoc-pdf-debugger", "/path/to/file.pdf"},
want: []string{"/path/to/file.pdf"},
},
{
name: "empty args slice",
args: []string{},
want: nil,
},
{
name: "nil args slice",
args: nil,
want: nil,
},
{
name: "no args beyond binary name",
args: []string{"unidoc-pdf-debugger"},
want: nil,
},
{
name: "non-pdf arg",
args: []string{"unidoc-pdf-debugger", "/path/to/file.txt"},
want: nil,
},
{
name: "mixed extensions including uppercase PDF",
args: []string{"unidoc-pdf-debugger", "/a.pdf", "/b.PDF", "/c.txt"},
want: []string{"/a.pdf", "/b.PDF"},
},
{
name: "multiple pdfs",
args: []string{"unidoc-pdf-debugger", "/doc1.pdf", "/doc2.pdf"},
want: []string{"/doc1.pdf", "/doc2.pdf"},
},
{
name: "mixed case extension",
args: []string{"unidoc-pdf-debugger", "/file.Pdf"},
want: []string{"/file.Pdf"},
},
{
name: "path with spaces",
args: []string{"unidoc-pdf-debugger", "/my docs/my file.pdf"},
want: []string{"/my docs/my file.pdf"},
},
{
name: "arg with no extension",
args: []string{"unidoc-pdf-debugger", "noext"},
want: nil,
},
{
name: "bare .pdf extension only",
args: []string{"unidoc-pdf-debugger", ".pdf"},
want: []string{".pdf"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := extractPDFPaths(tt.args)
if len(got) != len(tt.want) {
t.Fatalf("extractPDFPaths(%v) = %v, want %v", tt.args, got, tt.want)
}
for i := range got {
if got[i] != tt.want[i] {
t.Errorf("extractPDFPaths(%v)[%d] = %q, want %q", tt.args, i, got[i], tt.want[i])
}
}
})
}
}
// TestRouteOpenPath pins the routing decision shared by both
// file-open callbacks. Before Drain (cold start) the path is queued and the
// fake open func is NOT called; after Drain (warm/ready) the open func IS
// called and the verdict is true. This is the only sanctioned automated pin on
// the main.go wiring (source-grep tests are forbidden).
func TestRouteOpenPath(t *testing.T) {
tests := []struct {
name string
ready bool
wantOpen bool
wantVerdict bool
}{
{name: "cold queues without opening", ready: false, wantOpen: false, wantVerdict: false},
{name: "warm opens immediately", ready: true, wantOpen: true, wantVerdict: true},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
q := &pendingopen.Queue{}
if tc.ready {
q.Drain() // flip ready
}
var opened []string
open := func(p string) { opened = append(opened, p) }
verdict := routeOpenPath(q, "/x.pdf", open)
if verdict != tc.wantVerdict {
t.Fatalf("verdict: want %v got %v", tc.wantVerdict, verdict)
}
if got := len(opened) > 0; got != tc.wantOpen {
t.Fatalf("open called: want %v got %v (opened=%#v)", tc.wantOpen, got, opened)
}
if !tc.ready {
// Cold path: the queued path must surface on the next Drain.
if paths := q.Drain(); len(paths) != 1 || paths[0] != "/x.pdf" {
t.Fatalf("cold path must queue for drain, got %#v", paths)
}
}
})
}
}