@learnpack/learnpack 5.0.361 → 5.0.363
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/lib/commands/serve.js +44 -7
- package/lib/creatorDist/assets/{index-B7s5Nmq8.css → index-Cco3XhDR.css} +96 -0
- package/lib/creatorDist/assets/{index-CqVEKiJR.js → index-o3Cw0bsF.js} +69682 -66808
- package/lib/creatorDist/index.html +2 -2
- package/lib/utils/api.d.ts +0 -9
- package/lib/utils/api.js +1 -65
- package/lib/utils/lessonRecovery.d.ts +18 -0
- package/lib/utils/lessonRecovery.js +31 -0
- package/package.json +1 -1
- package/src/commands/serve.ts +65 -18
- package/src/creator/package-lock.json +297 -3
- package/src/creator/package.json +1 -0
- package/src/creator/src/App.test.tsx +124 -1
- package/src/creator/src/App.tsx +52 -36
- package/src/creator/src/components/FileCard.tsx +63 -19
- package/src/creator/src/components/FileReadListener.test.tsx +128 -0
- package/src/creator/src/components/FileReadListener.tsx +75 -0
- package/src/creator/src/components/FileReviewModal.test.tsx +193 -0
- package/src/creator/src/components/FileReviewModal.tsx +128 -0
- package/src/creator/src/components/FileUploader.test.tsx +229 -0
- package/src/creator/src/components/FileUploader.tsx +212 -58
- package/src/creator/src/components/GenerationErrorCard.tsx +57 -0
- package/src/creator/src/components/MarkdownRenderer.tsx +4 -2
- package/src/creator/src/components/syllabus/SyllabusEditor.tsx +6 -9
- package/src/creator/src/index.css +45 -0
- package/src/creator/src/locales/en.json +27 -5
- package/src/creator/src/locales/es.json +27 -5
- package/src/creator/src/main.tsx +2 -0
- package/src/creator/src/utils/completionResult.test.ts +61 -0
- package/src/creator/src/utils/completionResult.ts +61 -2
- package/src/creator/src/utils/courseLanguage.test.ts +6 -0
- package/src/creator/src/utils/creationRequest.test.ts +53 -18
- package/src/creator/src/utils/creationRequest.ts +17 -12
- package/src/creator/src/utils/fileRead.ts +60 -0
- package/src/creator/src/utils/lib.ts +0 -5
- package/src/creator/src/utils/store.ts +0 -10
- package/src/creatorDist/assets/{index-B7s5Nmq8.css → index-Cco3XhDR.css} +96 -0
- package/src/creatorDist/assets/{index-CqVEKiJR.js → index-o3Cw0bsF.js} +69682 -66808
- package/src/creatorDist/index.html +2 -2
- package/src/utils/api.ts +0 -83
- package/src/utils/lessonRecovery.ts +32 -0
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
import { describe, it, expect, vi, afterEach, beforeEach } from "vitest";
|
|
2
|
+
import { act, cleanup, fireEvent, render, screen } from "@testing-library/react";
|
|
3
|
+
import toast from "react-hot-toast";
|
|
4
|
+
import "../i18n";
|
|
5
|
+
import { FileCard } from "./FileCard";
|
|
6
|
+
import { FileReviewButton } from "./FileReviewModal";
|
|
7
|
+
import { MarkdownRenderer } from "./MarkdownRenderer";
|
|
8
|
+
import useStore from "../utils/store";
|
|
9
|
+
import { TooltipProvider } from "@/components/ui/tooltip";
|
|
10
|
+
import type { ParsedFile, ReadReport } from "./FileUploader";
|
|
11
|
+
|
|
12
|
+
vi.mock("react-hot-toast");
|
|
13
|
+
|
|
14
|
+
const report: ReadReport = {
|
|
15
|
+
total_pages: 3,
|
|
16
|
+
ai_pages: [],
|
|
17
|
+
unread_pages: [],
|
|
18
|
+
failed_pages: [],
|
|
19
|
+
markdown: true,
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
const file = (changes: Partial<ParsedFile> = {}): ParsedFile => ({
|
|
23
|
+
name: "seed.pdf",
|
|
24
|
+
text: "\n[Page 1]\n|Term|Definition|\n|---|---|\n|Loop|Repeats|\n",
|
|
25
|
+
status: "SUCCESS",
|
|
26
|
+
notificationId: "read-1",
|
|
27
|
+
report,
|
|
28
|
+
...changes,
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
const storedText = () => useStore.getState().uploadedFiles[0].text;
|
|
32
|
+
|
|
33
|
+
const open = (name: string) => fireEvent.click(screen.getByText(name));
|
|
34
|
+
|
|
35
|
+
beforeEach(() => {
|
|
36
|
+
vi.resetAllMocks();
|
|
37
|
+
useStore.getState().setUploadedFiles([file()]);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
afterEach(cleanup);
|
|
41
|
+
|
|
42
|
+
describe("FileReviewButton", () => {
|
|
43
|
+
it("stays quiet on an ordinary read", () => {
|
|
44
|
+
render(<FileReviewButton file={file()} />);
|
|
45
|
+
|
|
46
|
+
expect(screen.getByText("View text")).toBeTruthy();
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("asks for a review when the AI read pages", () => {
|
|
50
|
+
render(<FileReviewButton file={file({ report: { ...report, ai_pages: [1, 2] } })} />);
|
|
51
|
+
|
|
52
|
+
open("Review text");
|
|
53
|
+
|
|
54
|
+
expect(
|
|
55
|
+
screen.getByText("We read 2 of 3 pages with AI. Check names, figures and dates.")
|
|
56
|
+
).toBeTruthy();
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it("names the pages that are missing, without repeating them", () => {
|
|
60
|
+
render(
|
|
61
|
+
<FileReviewButton
|
|
62
|
+
file={file({ report: { ...report, unread_pages: [3, 2], failed_pages: [2] } })}
|
|
63
|
+
/>
|
|
64
|
+
);
|
|
65
|
+
|
|
66
|
+
open("Review text");
|
|
67
|
+
|
|
68
|
+
expect(screen.getByText("We could not fully read these pages: 2, 3.")).toBeTruthy();
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it("draws tables instead of showing their markdown", () => {
|
|
72
|
+
render(<FileReviewButton file={file()} />);
|
|
73
|
+
|
|
74
|
+
open("View text");
|
|
75
|
+
|
|
76
|
+
expect(screen.getByRole("table")).toBeTruthy();
|
|
77
|
+
expect(screen.getByRole("cell", { name: "Repeats" })).toBeTruthy();
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it("shows page headings on screen and keeps the markers in the stored text", () => {
|
|
81
|
+
render(<FileReviewButton file={file()} />);
|
|
82
|
+
|
|
83
|
+
open("View text");
|
|
84
|
+
|
|
85
|
+
expect(screen.getByRole("heading", { name: "Page 1" })).toBeTruthy();
|
|
86
|
+
expect(screen.queryByText("[Page 1]")).toBeNull();
|
|
87
|
+
expect(storedText()).toContain("[Page 1]");
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it("keeps the lines of plain text apart", () => {
|
|
91
|
+
render(<FileReviewButton file={file({ text: "One\nTwo", report: undefined })} />);
|
|
92
|
+
|
|
93
|
+
open("View text");
|
|
94
|
+
|
|
95
|
+
expect(screen.getByText(/One/).className).toContain("whitespace-pre-wrap");
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it("saves edits where every prompt reads the file from", () => {
|
|
99
|
+
render(<FileReviewButton file={file()} />);
|
|
100
|
+
open("View text");
|
|
101
|
+
open("Edit");
|
|
102
|
+
|
|
103
|
+
fireEvent.change(screen.getByRole("textbox"), {
|
|
104
|
+
target: { value: "Corrected text" },
|
|
105
|
+
});
|
|
106
|
+
open("Save changes");
|
|
107
|
+
|
|
108
|
+
expect(storedText()).toBe("Corrected text");
|
|
109
|
+
expect(screen.queryByText("Save changes")).toBeNull();
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it("discards edits on close", () => {
|
|
113
|
+
render(<FileReviewButton file={file()} />);
|
|
114
|
+
open("View text");
|
|
115
|
+
open("Edit");
|
|
116
|
+
|
|
117
|
+
fireEvent.change(screen.getByRole("textbox"), { target: { value: "Changed" } });
|
|
118
|
+
open("Close");
|
|
119
|
+
|
|
120
|
+
expect(storedText()).toBe(file().text);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it("does not let the user save an empty document", () => {
|
|
124
|
+
render(<FileReviewButton file={file()} />);
|
|
125
|
+
open("View text");
|
|
126
|
+
open("Edit");
|
|
127
|
+
|
|
128
|
+
fireEvent.change(screen.getByRole("textbox"), { target: { value: " " } });
|
|
129
|
+
|
|
130
|
+
expect(
|
|
131
|
+
(screen.getByText("Save changes") as HTMLButtonElement).disabled
|
|
132
|
+
).toBe(true);
|
|
133
|
+
});
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
describe("MarkdownRenderer", () => {
|
|
137
|
+
it("leaves the chat's markdown without table support", () => {
|
|
138
|
+
render(<MarkdownRenderer content={"|a|b|\n|---|---|\n|1|2|"} />);
|
|
139
|
+
|
|
140
|
+
expect(screen.queryByRole("table")).toBeNull();
|
|
141
|
+
});
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
describe("FileCard", () => {
|
|
145
|
+
const renderCard = (value: ParsedFile) =>
|
|
146
|
+
render(
|
|
147
|
+
<TooltipProvider>
|
|
148
|
+
<FileCard file={value} handleRemove={() => {}} />
|
|
149
|
+
</TooltipProvider>
|
|
150
|
+
);
|
|
151
|
+
|
|
152
|
+
const rerenderCard = (
|
|
153
|
+
rerender: ReturnType<typeof render>["rerender"],
|
|
154
|
+
value: ParsedFile
|
|
155
|
+
) =>
|
|
156
|
+
act(() =>
|
|
157
|
+
rerender(
|
|
158
|
+
<TooltipProvider>
|
|
159
|
+
<FileCard file={value} handleRemove={() => {}} />
|
|
160
|
+
</TooltipProvider>
|
|
161
|
+
)
|
|
162
|
+
);
|
|
163
|
+
|
|
164
|
+
it("warns once when a read from the sidebar used the AI", () => {
|
|
165
|
+
const { rerender } = renderCard(
|
|
166
|
+
file({ status: "PROCESSING", text: "", report: undefined })
|
|
167
|
+
);
|
|
168
|
+
|
|
169
|
+
const finished = file({ report: { ...report, ai_pages: [1] } });
|
|
170
|
+
rerenderCard(rerender, finished);
|
|
171
|
+
rerenderCard(rerender, { ...finished });
|
|
172
|
+
|
|
173
|
+
expect(toast).toHaveBeenCalledTimes(1);
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
it("says nothing when the page reloads with a read already finished", () => {
|
|
177
|
+
renderCard(file({ report: { ...report, ai_pages: [1] } }));
|
|
178
|
+
|
|
179
|
+
expect(toast).not.toHaveBeenCalled();
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
it("reports a failed read", () => {
|
|
183
|
+
const { rerender } = renderCard(
|
|
184
|
+
file({ status: "PROCESSING", text: "", report: undefined })
|
|
185
|
+
);
|
|
186
|
+
|
|
187
|
+
rerenderCard(rerender, file({ status: "ERROR", text: "", report: undefined }));
|
|
188
|
+
|
|
189
|
+
expect(toast.error).toHaveBeenCalledWith(
|
|
190
|
+
"We could not read this file. Remove it or upload it again."
|
|
191
|
+
);
|
|
192
|
+
});
|
|
193
|
+
});
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import { useState } from "react"
|
|
2
|
+
import { createPortal } from "react-dom"
|
|
3
|
+
import { useTranslation } from "react-i18next"
|
|
4
|
+
import useStore from "../utils/store"
|
|
5
|
+
import { isMarkdownRead, readNotices, withPageHeadings } from "../utils/fileRead"
|
|
6
|
+
import { MarkdownRenderer } from "./MarkdownRenderer"
|
|
7
|
+
import type { ParsedFile } from "./FileUploader"
|
|
8
|
+
|
|
9
|
+
const tabClass = (active: boolean) =>
|
|
10
|
+
"px-3 py-1 rounded-md cursor-pointer text-sm " +
|
|
11
|
+
(active ? "bg-blue-600 text-white" : "bg-gray-200 text-gray-800")
|
|
12
|
+
|
|
13
|
+
// Rendered in a portal because the sidebar is transformed on small screens,
|
|
14
|
+
// and a fixed element inside a transformed parent stops covering the page.
|
|
15
|
+
export const FileReviewModal = ({
|
|
16
|
+
file,
|
|
17
|
+
onClose,
|
|
18
|
+
}: {
|
|
19
|
+
file: ParsedFile
|
|
20
|
+
onClose: () => void
|
|
21
|
+
}) => {
|
|
22
|
+
const { t } = useTranslation()
|
|
23
|
+
const [mode, setMode] = useState<"read" | "edit">("read")
|
|
24
|
+
const [draft, setDraft] = useState(file.text)
|
|
25
|
+
|
|
26
|
+
const notices = readNotices(file, t)
|
|
27
|
+
const canSave = draft.trim().length > 0 && draft !== file.text
|
|
28
|
+
|
|
29
|
+
const save = () => {
|
|
30
|
+
const { uploadedFiles, setUploadedFiles } = useStore.getState()
|
|
31
|
+
setUploadedFiles(
|
|
32
|
+
uploadedFiles.map((item) =>
|
|
33
|
+
item.name === file.name ? { ...item, text: draft } : item
|
|
34
|
+
)
|
|
35
|
+
)
|
|
36
|
+
onClose()
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
return createPortal(
|
|
40
|
+
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-1000">
|
|
41
|
+
<div className="bg-white p-6 rounded-xl shadow-md max-w-3xl w-full mx-4 max-h-[90vh] flex flex-col gap-3 text-left text-gray-800">
|
|
42
|
+
<h2 className="text-xl font-semibold truncate">{file.name}</h2>
|
|
43
|
+
|
|
44
|
+
{notices.map((notice) => (
|
|
45
|
+
<p key={notice} className="text-sm bg-amber-50 border border-amber-300 rounded-md p-2">
|
|
46
|
+
{notice}
|
|
47
|
+
</p>
|
|
48
|
+
))}
|
|
49
|
+
|
|
50
|
+
<div className="flex gap-2">
|
|
51
|
+
<button className={tabClass(mode === "read")} onClick={() => setMode("read")}>
|
|
52
|
+
{t("uploader.files.reviewRead")}
|
|
53
|
+
</button>
|
|
54
|
+
<button className={tabClass(mode === "edit")} onClick={() => setMode("edit")}>
|
|
55
|
+
{t("uploader.files.reviewEdit")}
|
|
56
|
+
</button>
|
|
57
|
+
</div>
|
|
58
|
+
|
|
59
|
+
{mode === "read" ? (
|
|
60
|
+
<div className="file-read overflow-y-auto min-h-48 flex-1 text-sm border border-gray-200 rounded-md p-3">
|
|
61
|
+
{isMarkdownRead(file) ? (
|
|
62
|
+
<MarkdownRenderer content={withPageHeadings(draft, t)} gfm />
|
|
63
|
+
) : (
|
|
64
|
+
<p className="whitespace-pre-wrap">{draft}</p>
|
|
65
|
+
)}
|
|
66
|
+
</div>
|
|
67
|
+
) : (
|
|
68
|
+
<textarea
|
|
69
|
+
aria-label={t("uploader.files.reviewEdit")}
|
|
70
|
+
className="min-h-48 h-[50vh] w-full text-sm border border-gray-200 rounded-md p-3 font-mono"
|
|
71
|
+
value={draft}
|
|
72
|
+
onChange={(e) => setDraft(e.target.value)}
|
|
73
|
+
/>
|
|
74
|
+
)}
|
|
75
|
+
|
|
76
|
+
<p className="text-xs text-gray-500">{t("uploader.files.reviewEditNote")}</p>
|
|
77
|
+
|
|
78
|
+
<div className="flex justify-end gap-2">
|
|
79
|
+
<button
|
|
80
|
+
className="bg-gray-200 text-gray-800 px-4 py-2 rounded-md cursor-pointer"
|
|
81
|
+
onClick={onClose}
|
|
82
|
+
>
|
|
83
|
+
{t("uploader.files.reviewClose")}
|
|
84
|
+
</button>
|
|
85
|
+
<button
|
|
86
|
+
className="bg-blue-600 text-white px-4 py-2 rounded-md cursor-pointer disabled:opacity-50"
|
|
87
|
+
disabled={!canSave}
|
|
88
|
+
onClick={save}
|
|
89
|
+
>
|
|
90
|
+
{t("uploader.files.reviewSave")}
|
|
91
|
+
</button>
|
|
92
|
+
</div>
|
|
93
|
+
</div>
|
|
94
|
+
</div>,
|
|
95
|
+
document.body
|
|
96
|
+
)
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Opens the review of a file. The label asks for attention only when the read
|
|
101
|
+
* has something to check, so an ordinary PDF stays one quiet click away.
|
|
102
|
+
*/
|
|
103
|
+
export const FileReviewButton = ({
|
|
104
|
+
file,
|
|
105
|
+
label,
|
|
106
|
+
className = "",
|
|
107
|
+
}: {
|
|
108
|
+
file: ParsedFile
|
|
109
|
+
label?: string
|
|
110
|
+
className?: string
|
|
111
|
+
}) => {
|
|
112
|
+
const { t } = useTranslation()
|
|
113
|
+
const [isOpen, setIsOpen] = useState(false)
|
|
114
|
+
|
|
115
|
+
const needsReview = readNotices(file, t).length > 0
|
|
116
|
+
|
|
117
|
+
return (
|
|
118
|
+
<>
|
|
119
|
+
<button
|
|
120
|
+
className={"cursor-pointer " + (needsReview ? "font-semibold " : "") + className}
|
|
121
|
+
onClick={() => setIsOpen(true)}
|
|
122
|
+
>
|
|
123
|
+
{label ?? t(needsReview ? "uploader.files.reviewText" : "uploader.files.viewText")}
|
|
124
|
+
</button>
|
|
125
|
+
{isOpen && <FileReviewModal file={file} onClose={() => setIsOpen(false)} />}
|
|
126
|
+
</>
|
|
127
|
+
)
|
|
128
|
+
}
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
import { describe, it, expect, vi, afterEach, beforeEach } from "vitest";
|
|
2
|
+
import { act, cleanup, fireEvent, render, screen } from "@testing-library/react";
|
|
3
|
+
import axios from "axios";
|
|
4
|
+
import toast from "react-hot-toast";
|
|
5
|
+
import "../i18n";
|
|
6
|
+
import FileUploader from "./FileUploader";
|
|
7
|
+
import useStore from "../utils/store";
|
|
8
|
+
import { TooltipProvider } from "@/components/ui/tooltip";
|
|
9
|
+
|
|
10
|
+
vi.mock("axios");
|
|
11
|
+
vi.mock("react-hot-toast");
|
|
12
|
+
|
|
13
|
+
const post = vi.mocked(axios.post);
|
|
14
|
+
|
|
15
|
+
const pdf = (name = "seed.pdf") =>
|
|
16
|
+
new File(["%PDF"], name, { type: "application/pdf" });
|
|
17
|
+
|
|
18
|
+
const picker = () =>
|
|
19
|
+
document.querySelector('input[type="file"]') as HTMLInputElement;
|
|
20
|
+
|
|
21
|
+
const choose = async (...files: File[]) => {
|
|
22
|
+
await act(async () => {
|
|
23
|
+
fireEvent.change(picker(), { target: { files } });
|
|
24
|
+
});
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
const renderUploader = () =>
|
|
28
|
+
render(
|
|
29
|
+
<TooltipProvider>
|
|
30
|
+
<FileUploader styledAs="card" />
|
|
31
|
+
</TooltipProvider>
|
|
32
|
+
);
|
|
33
|
+
|
|
34
|
+
const files = () => useStore.getState().uploadedFiles;
|
|
35
|
+
|
|
36
|
+
beforeEach(() => {
|
|
37
|
+
vi.resetAllMocks();
|
|
38
|
+
useStore.getState().setUploadedFiles([]);
|
|
39
|
+
post.mockResolvedValue({ status: 202, data: { status: "PROCESSING" } });
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
afterEach(cleanup);
|
|
43
|
+
|
|
44
|
+
describe("FileUploader", () => {
|
|
45
|
+
it("adds the file as being read and sends the interface language", async () => {
|
|
46
|
+
renderUploader();
|
|
47
|
+
|
|
48
|
+
await choose(pdf());
|
|
49
|
+
|
|
50
|
+
expect(files()).toMatchObject([{ name: "seed.pdf", status: "PROCESSING" }]);
|
|
51
|
+
const form = post.mock.calls[0][1] as FormData;
|
|
52
|
+
expect(form.get("lang")).toBe("en");
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("replaces a file with the same name instead of adding a second one", async () => {
|
|
56
|
+
useStore.getState().setUploadedFiles([
|
|
57
|
+
{ name: "seed.pdf", text: "old", status: "SUCCESS", notificationId: "old" },
|
|
58
|
+
{ name: "other.pdf", text: "keep", status: "SUCCESS", notificationId: "other" },
|
|
59
|
+
]);
|
|
60
|
+
renderUploader();
|
|
61
|
+
|
|
62
|
+
await choose(pdf());
|
|
63
|
+
|
|
64
|
+
expect(files().map((file) => file.name)).toEqual(["other.pdf", "seed.pdf"]);
|
|
65
|
+
expect(files()[1]).toMatchObject({ status: "PROCESSING", text: "" });
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it("keeps a read that finished while another file was uploading", async () => {
|
|
69
|
+
useStore.getState().setUploadedFiles([
|
|
70
|
+
{ name: "first.pdf", text: "", status: "PROCESSING", notificationId: "1" },
|
|
71
|
+
]);
|
|
72
|
+
let finishUpload: (value: unknown) => void = () => {};
|
|
73
|
+
post.mockReturnValue(new Promise((resolve) => (finishUpload = resolve)) as never);
|
|
74
|
+
renderUploader();
|
|
75
|
+
|
|
76
|
+
await choose(pdf("second.pdf"));
|
|
77
|
+
act(() =>
|
|
78
|
+
useStore.getState().setUploadedFiles([
|
|
79
|
+
{ name: "first.pdf", text: "Done", status: "SUCCESS", notificationId: "1" },
|
|
80
|
+
])
|
|
81
|
+
);
|
|
82
|
+
await act(async () => {
|
|
83
|
+
finishUpload({ status: 202, data: { status: "PROCESSING" } });
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
expect(files()).toMatchObject([
|
|
87
|
+
{ name: "first.pdf", status: "SUCCESS", text: "Done" },
|
|
88
|
+
{ name: "second.pdf", status: "PROCESSING" },
|
|
89
|
+
]);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it("does not lose the current file when its replacement is rejected", async () => {
|
|
93
|
+
useStore.getState().setUploadedFiles([
|
|
94
|
+
{ name: "seed.pdf", text: "good", status: "SUCCESS", notificationId: "1" },
|
|
95
|
+
]);
|
|
96
|
+
post.mockRejectedValue({ response: { status: 400, data: { detail: "Nope" } } });
|
|
97
|
+
renderUploader();
|
|
98
|
+
|
|
99
|
+
await choose(pdf());
|
|
100
|
+
|
|
101
|
+
expect(files()).toMatchObject([{ text: "good", status: "SUCCESS" }]);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it("shows the reason rigobot gives for rejecting a file", async () => {
|
|
105
|
+
post.mockRejectedValue({
|
|
106
|
+
response: { status: 400, data: { detail: "Este PDF tiene contraseña." } },
|
|
107
|
+
});
|
|
108
|
+
renderUploader();
|
|
109
|
+
|
|
110
|
+
await choose(pdf());
|
|
111
|
+
|
|
112
|
+
expect(toast.error).toHaveBeenCalledWith(
|
|
113
|
+
"❌ Este PDF tiene contraseña.",
|
|
114
|
+
expect.anything()
|
|
115
|
+
);
|
|
116
|
+
expect(files()).toEqual([]);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
describe("a PDF that is mostly images", () => {
|
|
120
|
+
const needsConfirmation = (imagePages = 8) => ({
|
|
121
|
+
status: 200,
|
|
122
|
+
data: {
|
|
123
|
+
status: "NEEDS_CONFIRMATION",
|
|
124
|
+
report: { total_pages: 12, image_pages: imagePages, blank_pages: 0 },
|
|
125
|
+
ai_limit: 15,
|
|
126
|
+
},
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
const finishButton = () =>
|
|
130
|
+
screen.getByRole("button", { name: "🚀 Finish" }) as HTMLButtonElement;
|
|
131
|
+
|
|
132
|
+
beforeEach(() => {
|
|
133
|
+
post.mockResolvedValueOnce(needsConfirmation());
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it("asks first, using no AI and blocking Finish until the user chooses", async () => {
|
|
137
|
+
renderUploader();
|
|
138
|
+
|
|
139
|
+
await choose(pdf());
|
|
140
|
+
|
|
141
|
+
const form = post.mock.calls[0][1] as FormData;
|
|
142
|
+
expect(form.get("preflight")).toBe("true");
|
|
143
|
+
expect(
|
|
144
|
+
screen.getByText("This PDF is saved almost entirely as images (8 of 12 pages).")
|
|
145
|
+
).toBeTruthy();
|
|
146
|
+
expect(files()).toEqual([]);
|
|
147
|
+
expect(finishButton().disabled).toBe(true);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it("sends the same file again without the check when the user accepts", async () => {
|
|
151
|
+
renderUploader();
|
|
152
|
+
await choose(pdf());
|
|
153
|
+
|
|
154
|
+
await act(async () => {
|
|
155
|
+
fireEvent.click(screen.getByText("Use this PDF"));
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
expect(post).toHaveBeenCalledTimes(2);
|
|
159
|
+
const form = post.mock.calls[1][1] as FormData;
|
|
160
|
+
expect(form.get("preflight")).toBeNull();
|
|
161
|
+
expect((form.get("file") as File).name).toBe("seed.pdf");
|
|
162
|
+
expect(files()).toMatchObject([{ name: "seed.pdf", status: "PROCESSING" }]);
|
|
163
|
+
expect(screen.queryByText("Use this PDF")).toBeNull();
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
it("opens the file picker and drops the upload for another version", async () => {
|
|
167
|
+
renderUploader();
|
|
168
|
+
await choose(pdf());
|
|
169
|
+
const click = vi.spyOn(picker(), "click");
|
|
170
|
+
|
|
171
|
+
fireEvent.click(screen.getByText("Upload another version"));
|
|
172
|
+
|
|
173
|
+
expect(click).toHaveBeenCalled();
|
|
174
|
+
expect(screen.queryByText("Use this PDF")).toBeNull();
|
|
175
|
+
expect(post).toHaveBeenCalledTimes(1);
|
|
176
|
+
expect(files()).toEqual([]);
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
it("clears the question when a file with the same name is accepted", async () => {
|
|
180
|
+
renderUploader();
|
|
181
|
+
await choose(pdf());
|
|
182
|
+
|
|
183
|
+
await choose(pdf());
|
|
184
|
+
|
|
185
|
+
expect(screen.queryByText("Use this PDF")).toBeNull();
|
|
186
|
+
expect(files()).toMatchObject([{ name: "seed.pdf", status: "PROCESSING" }]);
|
|
187
|
+
});
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
it("warns when a scan goes over the reading limit", async () => {
|
|
191
|
+
post.mockResolvedValueOnce({
|
|
192
|
+
status: 200,
|
|
193
|
+
data: {
|
|
194
|
+
status: "NEEDS_CONFIRMATION",
|
|
195
|
+
report: { total_pages: 30, image_pages: 30, blank_pages: 0 },
|
|
196
|
+
ai_limit: 15,
|
|
197
|
+
},
|
|
198
|
+
});
|
|
199
|
+
renderUploader();
|
|
200
|
+
|
|
201
|
+
await choose(pdf());
|
|
202
|
+
|
|
203
|
+
expect(screen.getByText("We will only read the first 15 pages.")).toBeTruthy();
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
it("reads straight away from the sidebar, which has no room to ask", async () => {
|
|
207
|
+
render(
|
|
208
|
+
<TooltipProvider>
|
|
209
|
+
<FileUploader />
|
|
210
|
+
</TooltipProvider>
|
|
211
|
+
);
|
|
212
|
+
|
|
213
|
+
await choose(pdf());
|
|
214
|
+
|
|
215
|
+
const form = post.mock.calls[0][1] as FormData;
|
|
216
|
+
expect(form.get("preflight")).toBeNull();
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
it("survives a network failure and is ready for another try", async () => {
|
|
220
|
+
post.mockRejectedValue(new Error("Network Error"));
|
|
221
|
+
renderUploader();
|
|
222
|
+
|
|
223
|
+
await choose(pdf());
|
|
224
|
+
|
|
225
|
+
expect(toast.error).toHaveBeenCalled();
|
|
226
|
+
expect(screen.queryByText("Processing...")).toBeNull();
|
|
227
|
+
expect(picker().value).toBe("");
|
|
228
|
+
});
|
|
229
|
+
});
|