@solhun/feedback-kit-core 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/dist/index.cjs +2366 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +1234 -0
- package/dist/index.d.ts +1234 -0
- package/dist/index.js +2268 -0
- package/dist/index.js.map +1 -0
- package/package.json +35 -0
- package/src/adapters/adapters.test.ts +730 -0
- package/src/adapters/body.ts +280 -0
- package/src/adapters/index.ts +19 -0
- package/src/adapters/lasso.live.test.ts +156 -0
- package/src/adapters/lasso.ts +316 -0
- package/src/adapters/linear.ts +39 -0
- package/src/adapters/notion.ts +30 -0
- package/src/config.test.ts +284 -0
- package/src/config.ts +396 -0
- package/src/context.test.ts +427 -0
- package/src/context.ts +326 -0
- package/src/diagnostics.test.ts +382 -0
- package/src/diagnostics.ts +502 -0
- package/src/guest-id.ts +36 -0
- package/src/index.ts +179 -0
- package/src/keepalive.test.ts +72 -0
- package/src/keepalive.ts +37 -0
- package/src/queue.test.ts +848 -0
- package/src/queue.ts +546 -0
- package/src/report.ts +58 -0
- package/src/ring-buffer.test.ts +62 -0
- package/src/ring-buffer.ts +42 -0
- package/src/source-attr.test.ts +77 -0
- package/src/source-attr.ts +86 -0
- package/src/types.ts +320 -0
- package/src/uuid.test.ts +116 -0
- package/src/uuid.ts +54 -0
- package/src/widget/controller.ts +224 -0
- package/src/widget/focus.ts +66 -0
- package/src/widget/index.ts +59 -0
- package/src/widget/modal.test.ts +472 -0
- package/src/widget/modal.ts +526 -0
- package/src/widget/pin.ts +110 -0
- package/src/widget/screenshot.ts +109 -0
|
@@ -0,0 +1,472 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
|
|
3
|
+
import type { FeedbackReport, FeedbackScreenshot } from "../types.js";
|
|
4
|
+
import type { QueueStatus, SubmitOutcome } from "../queue.js";
|
|
5
|
+
import type { ReportParts } from "../report.js";
|
|
6
|
+
import { WidgetController } from "./controller.js";
|
|
7
|
+
import {
|
|
8
|
+
COMMENT_REQUIRED_MESSAGE,
|
|
9
|
+
COMMENT_TOO_LONG_MESSAGE,
|
|
10
|
+
MODAL_ACTION_SEND,
|
|
11
|
+
MODAL_FIELD_COMMENT,
|
|
12
|
+
MODAL_FIELD_PRIORITY,
|
|
13
|
+
ReportModalController,
|
|
14
|
+
} from "./modal.js";
|
|
15
|
+
import {
|
|
16
|
+
SCREENSHOT_FAILED_MESSAGE,
|
|
17
|
+
SCREENSHOT_MAX_BASE64_BYTES,
|
|
18
|
+
screenshotBytes,
|
|
19
|
+
} from "./screenshot.js";
|
|
20
|
+
|
|
21
|
+
const SHOT: FeedbackScreenshot = {
|
|
22
|
+
base64: "c2NyZWVuc2hvdA==",
|
|
23
|
+
contentType: "image/png",
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
class TestQueue {
|
|
27
|
+
readonly submitted: FeedbackReport[] = [];
|
|
28
|
+
private readonly listeners = new Set<(status: QueueStatus) => void>();
|
|
29
|
+
private readonly outcomes = new Map<
|
|
30
|
+
string,
|
|
31
|
+
Pick<SubmitOutcome, "delivered" | "id">
|
|
32
|
+
>();
|
|
33
|
+
outcome: SubmitOutcome = { delivered: true, id: "server-1", queued: false };
|
|
34
|
+
pending = 0;
|
|
35
|
+
|
|
36
|
+
async submit(report: FeedbackReport): Promise<SubmitOutcome> {
|
|
37
|
+
this.submitted.push(report);
|
|
38
|
+
if (this.outcome.queued) {
|
|
39
|
+
this.pending += 1;
|
|
40
|
+
this.emit();
|
|
41
|
+
} else {
|
|
42
|
+
this.outcomes.set(report.clientSubmissionId, this.outcome);
|
|
43
|
+
}
|
|
44
|
+
return this.outcome;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async retryNow(): Promise<void> {
|
|
48
|
+
const current = this.submitted.at(-1);
|
|
49
|
+
if (current && this.pending > 0) {
|
|
50
|
+
this.outcomes.set(current.clientSubmissionId, { delivered: true, id: "retried" });
|
|
51
|
+
}
|
|
52
|
+
this.pending = 0;
|
|
53
|
+
this.emit();
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async size(): Promise<number> {
|
|
57
|
+
return this.pending;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
subscribe(listener: (status: QueueStatus) => void): () => void {
|
|
61
|
+
this.listeners.add(listener);
|
|
62
|
+
listener(this.status());
|
|
63
|
+
return () => this.listeners.delete(listener);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
getOutcome(clientSubmissionId: string): Pick<SubmitOutcome, "delivered" | "id"> | null {
|
|
67
|
+
return this.outcomes.get(clientSubmissionId) ?? null;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
settle(
|
|
71
|
+
clientSubmissionId: string,
|
|
72
|
+
outcome: Pick<SubmitOutcome, "delivered" | "id">,
|
|
73
|
+
remaining: number,
|
|
74
|
+
): void {
|
|
75
|
+
this.outcomes.set(clientSubmissionId, outcome);
|
|
76
|
+
this.pending = remaining;
|
|
77
|
+
this.emit();
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
private status(): QueueStatus {
|
|
81
|
+
return {
|
|
82
|
+
state: this.pending > 0 ? "pending" : "idle",
|
|
83
|
+
pending: this.pending,
|
|
84
|
+
lastError: null,
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
private emit(): void {
|
|
89
|
+
const status = this.status();
|
|
90
|
+
for (const listener of this.listeners) listener(status);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function reportFactory() {
|
|
95
|
+
let sequence = 0;
|
|
96
|
+
return async (parts: ReportParts): Promise<FeedbackReport> => {
|
|
97
|
+
sequence += 1;
|
|
98
|
+
return {
|
|
99
|
+
clientSubmissionId: `submission-${sequence}`,
|
|
100
|
+
...parts,
|
|
101
|
+
context: {
|
|
102
|
+
app: "modal-test",
|
|
103
|
+
screen: "/",
|
|
104
|
+
url: null,
|
|
105
|
+
sessionId: "session",
|
|
106
|
+
user: null,
|
|
107
|
+
source: { screenId: null, sourceFile: null },
|
|
108
|
+
platform: "native",
|
|
109
|
+
timezone: "Asia/Seoul",
|
|
110
|
+
clientTimestamp: "2026-08-07T00:00:00.000Z",
|
|
111
|
+
native: null,
|
|
112
|
+
web: null,
|
|
113
|
+
diagnostics: null,
|
|
114
|
+
extra: {},
|
|
115
|
+
},
|
|
116
|
+
createdAt: `2026-08-07T00:00:0${sequence}.000Z`,
|
|
117
|
+
};
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function nativeWidget(options: {
|
|
122
|
+
queue?: TestQueue;
|
|
123
|
+
capture?: () => FeedbackScreenshot | null | Promise<FeedbackScreenshot | null>;
|
|
124
|
+
reencode?: (
|
|
125
|
+
shot: FeedbackScreenshot,
|
|
126
|
+
quality: number,
|
|
127
|
+
) => FeedbackScreenshot | null | Promise<FeedbackScreenshot | null>;
|
|
128
|
+
} = {}) {
|
|
129
|
+
const queue = options.queue ?? new TestQueue();
|
|
130
|
+
const widget = new WidgetController({
|
|
131
|
+
queue,
|
|
132
|
+
createReport: reportFactory(),
|
|
133
|
+
platform: "native",
|
|
134
|
+
capture: options.capture ?? (() => SHOT),
|
|
135
|
+
reencode: options.reencode,
|
|
136
|
+
});
|
|
137
|
+
return { queue, widget };
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
describe("DP-258 리포트 모달", () => {
|
|
141
|
+
it("TC1 캡처에 실패해도 제출을 막지 않는다", async () => {
|
|
142
|
+
const { queue, widget } = nativeWidget({ capture: async () => { throw new Error("capture"); } });
|
|
143
|
+
await widget.openReport();
|
|
144
|
+
|
|
145
|
+
expect(widget.getState().modal.screenshotMessage).toBe(SCREENSHOT_FAILED_MESSAGE);
|
|
146
|
+
widget.modal.setComment("스크린샷 없이 전송");
|
|
147
|
+
expect(widget.getState().modal.canSubmit).toBe(true);
|
|
148
|
+
await widget.submitReport();
|
|
149
|
+
|
|
150
|
+
expect(queue.submitted).toHaveLength(1);
|
|
151
|
+
expect(queue.submitted[0]?.screenshot).toBeNull();
|
|
152
|
+
widget.dispose();
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it("TC2 핀은 한 화면에 하나만 유지된다", async () => {
|
|
156
|
+
const { widget } = nativeWidget();
|
|
157
|
+
await widget.openReport();
|
|
158
|
+
widget.openPinScreen();
|
|
159
|
+
widget.placePin({ x: 10, y: 20 }, { width: 100, height: 100 });
|
|
160
|
+
widget.placePin({ x: 70, y: 80 }, { width: 100, height: 100 });
|
|
161
|
+
|
|
162
|
+
expect(widget.pin.count).toBe(1);
|
|
163
|
+
expect(widget.getState().pinDraft).toEqual({ x: 0.7, y: 0.8 });
|
|
164
|
+
widget.dispose();
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it("TC3 코멘트가 비어 있으면 보낼 수 없다", async () => {
|
|
168
|
+
const { queue, widget } = nativeWidget();
|
|
169
|
+
await widget.openReport();
|
|
170
|
+
widget.modal.setComment(" ");
|
|
171
|
+
|
|
172
|
+
expect(widget.getState().modal.canSubmit).toBe(false);
|
|
173
|
+
expect(widget.getState().modal.commentError).toBe(COMMENT_REQUIRED_MESSAGE);
|
|
174
|
+
expect(await widget.submitReport()).toBeNull();
|
|
175
|
+
expect(queue.submitted).toHaveLength(0);
|
|
176
|
+
widget.dispose();
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
it("TC4 스크린샷이 없으면 핀 찍기를 쓸 수 없다", async () => {
|
|
180
|
+
const { widget } = nativeWidget();
|
|
181
|
+
await widget.openReport();
|
|
182
|
+
widget.modal.removeScreenshot();
|
|
183
|
+
|
|
184
|
+
expect(widget.getState().modal.canPin).toBe(false);
|
|
185
|
+
expect(widget.openPinScreen()).toBe(false);
|
|
186
|
+
expect(widget.getState().screen).toBe("modal");
|
|
187
|
+
widget.dispose();
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
it("TC5 스크린샷을 제거하면 없이 전송된다", async () => {
|
|
191
|
+
const { queue, widget } = nativeWidget();
|
|
192
|
+
await widget.openReport();
|
|
193
|
+
widget.modal.removeScreenshot();
|
|
194
|
+
widget.modal.setComment("이미지 제거");
|
|
195
|
+
await widget.submitReport();
|
|
196
|
+
|
|
197
|
+
expect(queue.submitted[0]?.screenshot).toBeNull();
|
|
198
|
+
widget.dispose();
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
it("TC6 모달을 키보드만으로 조작할 수 있다", async () => {
|
|
202
|
+
const { widget } = nativeWidget();
|
|
203
|
+
await widget.openReport();
|
|
204
|
+
const order = widget.getState().modal.focusOrder;
|
|
205
|
+
|
|
206
|
+
expect(order).toContain(MODAL_FIELD_COMMENT);
|
|
207
|
+
expect(order).toContain(MODAL_FIELD_PRIORITY);
|
|
208
|
+
expect(order).toContain(MODAL_ACTION_SEND);
|
|
209
|
+
widget.modal.focus(MODAL_ACTION_SEND);
|
|
210
|
+
expect(widget.modal.tabNext()).toBe(MODAL_FIELD_COMMENT);
|
|
211
|
+
expect(widget.closeReport()).toBe("closed");
|
|
212
|
+
expect(widget.getState().modal.restoreFocusTo).toBe("feedback-kit-floating-button");
|
|
213
|
+
widget.dispose();
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
it("TC7 모달을 열면 스크린샷이 자동 캡처된다", async () => {
|
|
217
|
+
let calls = 0;
|
|
218
|
+
const { widget } = nativeWidget({ capture: () => { calls += 1; return SHOT; } });
|
|
219
|
+
|
|
220
|
+
await widget.openReport();
|
|
221
|
+
|
|
222
|
+
expect(calls).toBe(1);
|
|
223
|
+
expect(widget.getState().modal.screenshot).toEqual(SHOT);
|
|
224
|
+
expect(widget.getState().modal.screenshotStatus).toBe("ready");
|
|
225
|
+
widget.dispose();
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
it("TC8 코멘트 4,000자 초과는 거부하고 자르지 않는다", async () => {
|
|
229
|
+
const { queue, widget } = nativeWidget();
|
|
230
|
+
await widget.openReport();
|
|
231
|
+
const value = "가".repeat(4001);
|
|
232
|
+
widget.modal.setComment(value);
|
|
233
|
+
|
|
234
|
+
expect(widget.getState().modal.comment).toBe(value);
|
|
235
|
+
expect(widget.getState().modal.commentError).toBe(COMMENT_TOO_LONG_MESSAGE);
|
|
236
|
+
expect(await widget.submitReport()).toBeNull();
|
|
237
|
+
expect(queue.submitted).toHaveLength(0);
|
|
238
|
+
widget.dispose();
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
it("TC9 스크린샷이 8 MiB를 넘으면 재인코딩한다", async () => {
|
|
242
|
+
const oversized: FeedbackScreenshot = {
|
|
243
|
+
base64: "a".repeat(SCREENSHOT_MAX_BASE64_BYTES + 1),
|
|
244
|
+
contentType: "image/png",
|
|
245
|
+
};
|
|
246
|
+
const compressed: FeedbackScreenshot = { base64: "small", contentType: "image/jpeg" };
|
|
247
|
+
const qualities: number[] = [];
|
|
248
|
+
const { widget } = nativeWidget({
|
|
249
|
+
capture: () => oversized,
|
|
250
|
+
reencode: (_shot, quality) => {
|
|
251
|
+
qualities.push(quality);
|
|
252
|
+
return compressed;
|
|
253
|
+
},
|
|
254
|
+
});
|
|
255
|
+
await widget.openReport();
|
|
256
|
+
|
|
257
|
+
expect(qualities).toEqual([0.7]);
|
|
258
|
+
expect(widget.getState().modal.screenshot).toEqual(compressed);
|
|
259
|
+
expect(screenshotBytes(widget.getState().modal.screenshot)).toBeLessThanOrEqual(
|
|
260
|
+
SCREENSHOT_MAX_BASE64_BYTES,
|
|
261
|
+
);
|
|
262
|
+
widget.dispose();
|
|
263
|
+
|
|
264
|
+
const failed = nativeWidget({ capture: () => oversized, reencode: () => oversized });
|
|
265
|
+
await failed.widget.openReport();
|
|
266
|
+
failed.widget.modal.setComment("용량 실패 뒤 제출");
|
|
267
|
+
expect(failed.widget.getState().modal.screenshotMessage).toBe(SCREENSHOT_FAILED_MESSAGE);
|
|
268
|
+
expect(failed.widget.getState().modal.canSubmit).toBe(true);
|
|
269
|
+
failed.widget.dispose();
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
it("TC10 입력 중 모달을 닫으려 하면 확인을 받는다", async () => {
|
|
273
|
+
const { widget } = nativeWidget();
|
|
274
|
+
await widget.openReport();
|
|
275
|
+
widget.modal.setComment("작성 중");
|
|
276
|
+
|
|
277
|
+
expect(widget.closeReport()).toBe("confirm");
|
|
278
|
+
expect(widget.getState().modal.open).toBe(true);
|
|
279
|
+
expect(widget.getState().modal.closeConfirmVisible).toBe(true);
|
|
280
|
+
expect(widget.getState().modal.comment).toBe("작성 중");
|
|
281
|
+
widget.dispose();
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
it("TC11 핀 지정을 취소하면 이전 좌표가 유지된다", async () => {
|
|
285
|
+
const { widget } = nativeWidget();
|
|
286
|
+
await widget.openReport();
|
|
287
|
+
widget.openPinScreen();
|
|
288
|
+
widget.placePin({ x: 20, y: 30 }, { width: 100, height: 100 });
|
|
289
|
+
widget.confirmPin();
|
|
290
|
+
widget.openPinScreen();
|
|
291
|
+
widget.placePin({ x: 80, y: 90 }, { width: 100, height: 100 });
|
|
292
|
+
|
|
293
|
+
widget.cancelPin();
|
|
294
|
+
|
|
295
|
+
expect(widget.getState().modal.pin).toEqual({ x: 0.2, y: 0.3 });
|
|
296
|
+
expect(widget.getState().screen).toBe("modal");
|
|
297
|
+
widget.dispose();
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
it("TC12 핀 좌표가 해상도와 무관한 상대 좌표로 저장된다", async () => {
|
|
301
|
+
const first = nativeWidget();
|
|
302
|
+
const second = nativeWidget();
|
|
303
|
+
await first.widget.openReport();
|
|
304
|
+
await second.widget.openReport();
|
|
305
|
+
first.widget.openPinScreen();
|
|
306
|
+
second.widget.openPinScreen();
|
|
307
|
+
first.widget.placePin({ x: 50, y: 75 }, { width: 100, height: 100 });
|
|
308
|
+
second.widget.placePin({ x: 500, y: 750 }, { width: 1000, height: 1000 });
|
|
309
|
+
first.widget.confirmPin();
|
|
310
|
+
second.widget.confirmPin();
|
|
311
|
+
first.widget.modal.setComment("기기 1");
|
|
312
|
+
second.widget.modal.setComment("기기 2");
|
|
313
|
+
await first.widget.submitReport();
|
|
314
|
+
await second.widget.submitReport();
|
|
315
|
+
|
|
316
|
+
expect(first.queue.submitted[0]?.pin).toEqual({ x: 0.5, y: 0.75 });
|
|
317
|
+
expect(second.queue.submitted[0]?.pin).toEqual(first.queue.submitted[0]?.pin);
|
|
318
|
+
first.widget.dispose();
|
|
319
|
+
second.widget.dispose();
|
|
320
|
+
});
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
describe("리포트 모달 회귀", () => {
|
|
324
|
+
it("pending 상태에서는 보내기를 다시 눌러도 새 제보를 만들지 않는다", async () => {
|
|
325
|
+
const queue = new TestQueue();
|
|
326
|
+
queue.outcome = { delivered: false, id: null, queued: true };
|
|
327
|
+
const { widget } = nativeWidget({ queue });
|
|
328
|
+
await widget.openReport();
|
|
329
|
+
widget.modal.setComment("중복 방지");
|
|
330
|
+
await widget.submitReport();
|
|
331
|
+
|
|
332
|
+
expect(await widget.submitReport()).toBeNull();
|
|
333
|
+
expect(queue.submitted).toHaveLength(1);
|
|
334
|
+
widget.dispose();
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
it("제보 조립 실패를 대기 큐 성공으로 가장하지 않는다", async () => {
|
|
338
|
+
const queue = new TestQueue();
|
|
339
|
+
const modal = new ReportModalController({
|
|
340
|
+
queue,
|
|
341
|
+
platform: "web",
|
|
342
|
+
createReport: async () => { throw new Error("context"); },
|
|
343
|
+
});
|
|
344
|
+
await modal.open();
|
|
345
|
+
modal.setComment("조립 실패");
|
|
346
|
+
|
|
347
|
+
const outcome = await modal.submit();
|
|
348
|
+
|
|
349
|
+
expect(outcome).toEqual({ delivered: false, id: null, queued: false });
|
|
350
|
+
expect(modal.getState().submitStatus).toBe("failed");
|
|
351
|
+
expect(modal.getState().showRetry).toBe(false);
|
|
352
|
+
expect(queue.submitted).toHaveLength(0);
|
|
353
|
+
modal.dispose();
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
it("닫힌 이전 캡처가 다시 연 모달의 최신 캡처를 덮지 않는다", async () => {
|
|
357
|
+
let resolveFirst!: (shot: FeedbackScreenshot) => void;
|
|
358
|
+
const firstCapture = new Promise<FeedbackScreenshot>((resolve) => { resolveFirst = resolve; });
|
|
359
|
+
let calls = 0;
|
|
360
|
+
const { widget } = nativeWidget({
|
|
361
|
+
capture: () => {
|
|
362
|
+
calls += 1;
|
|
363
|
+
return calls === 1 ? firstCapture : SHOT;
|
|
364
|
+
},
|
|
365
|
+
});
|
|
366
|
+
const opening = widget.openReport();
|
|
367
|
+
widget.closeReport();
|
|
368
|
+
await widget.openReport();
|
|
369
|
+
resolveFirst({ base64: "stale", contentType: "image/png" });
|
|
370
|
+
await opening;
|
|
371
|
+
|
|
372
|
+
expect(widget.getState().modal.screenshot).toEqual(SHOT);
|
|
373
|
+
widget.dispose();
|
|
374
|
+
});
|
|
375
|
+
|
|
376
|
+
it("복원된 대기 큐도 인라인 재전송을 제공하고 새 입력을 닫지 않는다", async () => {
|
|
377
|
+
const queue = new TestQueue();
|
|
378
|
+
queue.pending = 1;
|
|
379
|
+
const { widget } = nativeWidget({ queue });
|
|
380
|
+
await widget.openReport();
|
|
381
|
+
widget.modal.setComment("새로 작성 중인 내용");
|
|
382
|
+
|
|
383
|
+
expect(widget.getState().modal.showRetry).toBe(true);
|
|
384
|
+
await widget.retryReport();
|
|
385
|
+
|
|
386
|
+
expect(widget.getState().modal.open).toBe(true);
|
|
387
|
+
expect(widget.getState().modal.comment).toBe("새로 작성 중인 내용");
|
|
388
|
+
expect(widget.getState().modal.pending).toBe(0);
|
|
389
|
+
widget.dispose();
|
|
390
|
+
});
|
|
391
|
+
|
|
392
|
+
it("현재 제보가 영구 실패하면 큐가 비어도 성공으로 표시하지 않는다", async () => {
|
|
393
|
+
const queue = new TestQueue();
|
|
394
|
+
queue.outcome = { delivered: false, id: null, queued: true };
|
|
395
|
+
const { widget } = nativeWidget({ queue });
|
|
396
|
+
await widget.openReport();
|
|
397
|
+
widget.modal.setComment("영구 실패");
|
|
398
|
+
await widget.submitReport();
|
|
399
|
+
const id = widget.modal.getLastReport()!.clientSubmissionId;
|
|
400
|
+
|
|
401
|
+
queue.settle(id, { delivered: false, id: null }, 0);
|
|
402
|
+
|
|
403
|
+
expect(widget.getState().modal.open).toBe(true);
|
|
404
|
+
expect(widget.getState().modal.submitStatus).toBe("failed");
|
|
405
|
+
widget.dispose();
|
|
406
|
+
});
|
|
407
|
+
|
|
408
|
+
it("현재 제보가 성공하면 관련 없는 대기 항목이 있어도 완료된다", async () => {
|
|
409
|
+
const queue = new TestQueue();
|
|
410
|
+
queue.pending = 1;
|
|
411
|
+
queue.outcome = { delivered: false, id: null, queued: true };
|
|
412
|
+
const { widget } = nativeWidget({ queue });
|
|
413
|
+
await widget.openReport();
|
|
414
|
+
widget.modal.setComment("현재 건 성공");
|
|
415
|
+
await widget.submitReport();
|
|
416
|
+
const id = widget.modal.getLastReport()!.clientSubmissionId;
|
|
417
|
+
|
|
418
|
+
queue.settle(id, { delivered: true, id: "server-current" }, 1);
|
|
419
|
+
|
|
420
|
+
expect(widget.getState().screen).toBe("button");
|
|
421
|
+
expect(widget.getState().modal.submitStatus).toBe("sent");
|
|
422
|
+
widget.dispose();
|
|
423
|
+
});
|
|
424
|
+
|
|
425
|
+
it("대기 중인 제보의 입력과 첨부는 큐 원본과 다르게 수정되지 않는다", async () => {
|
|
426
|
+
const queue = new TestQueue();
|
|
427
|
+
queue.outcome = { delivered: false, id: null, queued: true };
|
|
428
|
+
const { widget } = nativeWidget({ queue });
|
|
429
|
+
await widget.openReport();
|
|
430
|
+
widget.modal.setComment("최초 내용");
|
|
431
|
+
widget.modal.setPriority("high");
|
|
432
|
+
await widget.submitReport();
|
|
433
|
+
|
|
434
|
+
widget.modal.setComment("바뀐 내용");
|
|
435
|
+
widget.modal.setPriority("urgent");
|
|
436
|
+
widget.modal.removeScreenshot();
|
|
437
|
+
await widget.modal.attachFile({ base64: "replacement", contentType: "image/jpeg" });
|
|
438
|
+
|
|
439
|
+
expect(widget.getState().modal.comment).toBe("최초 내용");
|
|
440
|
+
expect(widget.getState().modal.priority).toBe("high");
|
|
441
|
+
expect(widget.getState().modal.screenshot).toEqual(SHOT);
|
|
442
|
+
expect(queue.submitted[0]?.comment).toBe("최초 내용");
|
|
443
|
+
widget.dispose();
|
|
444
|
+
});
|
|
445
|
+
|
|
446
|
+
it("스크린샷 교체는 이전 핀을 지우고 늦은 캡처가 파일을 덮지 못하게 한다", async () => {
|
|
447
|
+
let finishCapture!: (shot: FeedbackScreenshot) => void;
|
|
448
|
+
const delayed = new Promise<FeedbackScreenshot>((resolve) => { finishCapture = resolve; });
|
|
449
|
+
let calls = 0;
|
|
450
|
+
const replacement = { base64: "replacement", contentType: "image/jpeg" } as const;
|
|
451
|
+
const { widget } = nativeWidget({
|
|
452
|
+
capture: () => {
|
|
453
|
+
calls += 1;
|
|
454
|
+
return calls === 1 ? SHOT : delayed;
|
|
455
|
+
},
|
|
456
|
+
});
|
|
457
|
+
await widget.openReport();
|
|
458
|
+
widget.openPinScreen();
|
|
459
|
+
widget.placePin({ x: 20, y: 30 }, { width: 100, height: 100 });
|
|
460
|
+
widget.confirmPin();
|
|
461
|
+
|
|
462
|
+
const recapturing = widget.modal.recapture();
|
|
463
|
+
await widget.modal.attachFile(replacement);
|
|
464
|
+
finishCapture({ base64: "late", contentType: "image/png" });
|
|
465
|
+
await recapturing;
|
|
466
|
+
|
|
467
|
+
expect(widget.getState().modal.screenshot).toEqual(replacement);
|
|
468
|
+
expect(widget.getState().modal.pin).toBeNull();
|
|
469
|
+
expect(widget.getState().pinConfirmed).toBeNull();
|
|
470
|
+
widget.dispose();
|
|
471
|
+
});
|
|
472
|
+
});
|