mirror of
https://github.com/Stirling-Tools/Stirling-PDF.git
synced 2026-09-03 05:10:16 +03:00
Apply detected fields with PDFBox instead of pdf-lib
This commit is contained in:
@@ -1065,8 +1065,8 @@ public class FormUtils {
|
||||
definition.width(),
|
||||
definition.height());
|
||||
FormFieldTypeSupport handler = FormFieldTypeSupport.forTypeName(definition.type());
|
||||
// Coerced by name, not capability: detection results can also be applied client-side
|
||||
// with pdf-lib, which cannot create signature widgets, so both paths emit text.
|
||||
// Signature has no definition-creation path here, so it lands as text. PDFBox can build
|
||||
// a real PDSignatureField, so this is worth revisiting for both callers.
|
||||
if (handler == null
|
||||
|| handler == FormFieldTypeSupport.SIGNATURE
|
||||
|| handler.doesNotsupportsDefinitionCreation()) {
|
||||
|
||||
@@ -185,7 +185,8 @@ public class WebMvcConfig implements WebMvcConfigurer {
|
||||
"Content-Disposition",
|
||||
"Content-Type",
|
||||
"X-Stirling-Skipped-Field-Edits",
|
||||
"X-Stirling-Skipped-Field-Edits-Total")
|
||||
"X-Stirling-Skipped-Field-Edits-Total",
|
||||
"X-Stirling-Detected-Fields")
|
||||
.allowCredentials(true)
|
||||
.maxAge(3600);
|
||||
} else if (hasConfiguredOrigins) {
|
||||
@@ -233,7 +234,8 @@ public class WebMvcConfig implements WebMvcConfigurer {
|
||||
"Content-Disposition",
|
||||
"Content-Type",
|
||||
"X-Stirling-Skipped-Field-Edits",
|
||||
"X-Stirling-Skipped-Field-Edits-Total")
|
||||
"X-Stirling-Skipped-Field-Edits-Total",
|
||||
"X-Stirling-Detected-Fields")
|
||||
.allowCredentials(true)
|
||||
.maxAge(3600);
|
||||
} else {
|
||||
@@ -262,7 +264,8 @@ public class WebMvcConfig implements WebMvcConfigurer {
|
||||
"Content-Disposition",
|
||||
"Content-Type",
|
||||
"X-Stirling-Skipped-Field-Edits",
|
||||
"X-Stirling-Skipped-Field-Edits-Total")
|
||||
"X-Stirling-Skipped-Field-Edits-Total",
|
||||
"X-Stirling-Detected-Fields")
|
||||
.allowCredentials(true)
|
||||
.maxAge(3600);
|
||||
}
|
||||
|
||||
+31
-2
@@ -2,11 +2,14 @@ package stirling.software.proprietary.formdetection.controller;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.TreeSet;
|
||||
|
||||
import org.apache.pdfbox.pdmodel.PDDocument;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnClass;
|
||||
import org.springframework.core.io.Resource;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
@@ -32,6 +35,8 @@ import stirling.software.proprietary.formdetection.model.DetectedField;
|
||||
import stirling.software.proprietary.formdetection.render.PageRasterizer;
|
||||
import stirling.software.proprietary.formdetection.service.FormDetectionService;
|
||||
|
||||
import tools.jackson.databind.ObjectMapper;
|
||||
|
||||
/**
|
||||
* Detection endpoint, behind the {@code form-detection} key that is disabled until a model is
|
||||
* installed. Returns detected fields, or the applied PDF when {@code applyToPdf=true}.
|
||||
@@ -44,9 +49,13 @@ import stirling.software.proprietary.formdetection.service.FormDetectionService;
|
||||
@Tag(name = "Auto Form Detection")
|
||||
public class FormDetectionController {
|
||||
|
||||
/** Carries the field counts alongside the PDF, so one request feeds the results panel. */
|
||||
static final String SUMMARY_HEADER = "X-Stirling-Detected-Fields";
|
||||
|
||||
private final FormDetectionService detection;
|
||||
private final CustomPDFDocumentFactory pdfDocumentFactory;
|
||||
private final TempFileManager tempFileManager;
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
@PostMapping(value = "/detect", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
|
||||
@Operation(
|
||||
@@ -101,8 +110,13 @@ public class FormDetectionController {
|
||||
defs.add(toDefinition(f));
|
||||
}
|
||||
FormUtils.addFields(document, defs);
|
||||
return WebResponseUtils.pdfDocToWebResponse(
|
||||
document, baseName(file) + ".pdf", tempFileManager);
|
||||
ResponseEntity<Resource> pdf =
|
||||
WebResponseUtils.pdfDocToWebResponse(
|
||||
document, baseName(file) + ".pdf", tempFileManager);
|
||||
return ResponseEntity.status(pdf.getStatusCode())
|
||||
.headers(pdf.getHeaders())
|
||||
.header(SUMMARY_HEADER, summaryHeader(detections))
|
||||
.body(pdf.getBody());
|
||||
} catch (IOException e) {
|
||||
log.debug("Auto Form Detection could not apply fields: {}", e.getMessage());
|
||||
return ResponseEntity.badRequest()
|
||||
@@ -141,6 +155,21 @@ public class FormDetectionController {
|
||||
null);
|
||||
}
|
||||
|
||||
/** Compact JSON of what was added; a header keeps it to one request and one inference. */
|
||||
private String summaryHeader(List<DetectedField> detections) {
|
||||
Map<String, Integer> byType = new LinkedHashMap<>();
|
||||
TreeSet<Integer> pages = new TreeSet<>();
|
||||
for (DetectedField f : detections) {
|
||||
byType.merge(f.type(), 1, Integer::sum);
|
||||
pages.add(f.page());
|
||||
}
|
||||
return objectMapper.writeValueAsString(
|
||||
Map.of(
|
||||
"total", detections.size(),
|
||||
"byType", byType,
|
||||
"pagesWithFields", pages.size()));
|
||||
}
|
||||
|
||||
private static String baseName(MultipartFile file) {
|
||||
String original = Filenames.toSimpleFileName(file.getOriginalFilename());
|
||||
if (original == null || original.isBlank()) {
|
||||
|
||||
+2
-1
@@ -215,7 +215,8 @@ public class SecurityConfiguration {
|
||||
"Content-Disposition",
|
||||
"Content-Type",
|
||||
"X-Stirling-Skipped-Field-Edits",
|
||||
"X-Stirling-Skipped-Field-Edits-Total"));
|
||||
"X-Stirling-Skipped-Field-Edits-Total",
|
||||
"X-Stirling-Detected-Fields"));
|
||||
|
||||
cfg.setAllowCredentials(true);
|
||||
cfg.setMaxAge(3600L);
|
||||
|
||||
+4
-1
@@ -20,6 +20,8 @@ import stirling.software.proprietary.formdetection.render.PageRasterizer;
|
||||
import stirling.software.proprietary.formdetection.service.FormDetectionModelManager;
|
||||
import stirling.software.proprietary.formdetection.service.FormDetectionService;
|
||||
|
||||
import tools.jackson.databind.ObjectMapper;
|
||||
|
||||
class FormDetectionControllerTest {
|
||||
|
||||
private MockMvc mvc(
|
||||
@@ -32,7 +34,8 @@ class FormDetectionControllerTest {
|
||||
new FormDetectionController(
|
||||
new FormDetectionService(manager, detector, rasterizer),
|
||||
Mockito.mock(CustomPDFDocumentFactory.class),
|
||||
Mockito.mock(TempFileManager.class));
|
||||
Mockito.mock(TempFileManager.class),
|
||||
new ObjectMapper());
|
||||
return MockMvcBuilders.standaloneSetup(controller).build();
|
||||
}
|
||||
|
||||
|
||||
@@ -371,7 +371,8 @@ public class SupabaseSecurityConfig {
|
||||
List.of(
|
||||
"WWW-Authenticate",
|
||||
"X-Stirling-Skipped-Field-Edits",
|
||||
"X-Stirling-Skipped-Field-Edits-Total"));
|
||||
"X-Stirling-Skipped-Field-Edits-Total",
|
||||
"X-Stirling-Detected-Fields"));
|
||||
cfg.setAllowCredentials(true);
|
||||
cfg.setMaxAge(3600L);
|
||||
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
|
||||
|
||||
+33
-35
@@ -3,15 +3,6 @@ import { beforeEach, describe, expect, it, vi, type Mock } from "vitest";
|
||||
vi.mock("@app/services/apiClient", () => ({
|
||||
default: { get: vi.fn(), post: vi.fn() },
|
||||
}));
|
||||
vi.mock("@app/services/formDetection/progress", () => ({
|
||||
emitStage: vi.fn(),
|
||||
emitSummary: vi.fn(),
|
||||
summarizeFields: vi.fn(() => ({})),
|
||||
}));
|
||||
vi.mock("@app/services/formDetection/applyFields", () => ({
|
||||
applyFields: vi.fn(async () => new Uint8Array([1, 2, 3])),
|
||||
}));
|
||||
vi.mock("@app/hooks/useFormDetectionModelStatus", () => ({}));
|
||||
vi.mock("@app/hooks/tools/shared/useToolOperation", () => ({
|
||||
ToolType: { custom: "custom" },
|
||||
useToolOperation: vi.fn(),
|
||||
@@ -22,9 +13,8 @@ vi.mock("react-i18next", () => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
import { expectConsole } from "@app/tests/failOnConsole";
|
||||
import apiClient from "@app/services/apiClient";
|
||||
import { applyFields } from "@app/services/formDetection/applyFields";
|
||||
import { onSummary } from "@app/services/formDetection/progress";
|
||||
import { autoFormDetectionOperationConfig } from "@app/hooks/tools/autoFormDetection/useAutoFormDetectionOperation";
|
||||
import { defaultParameters } from "@app/hooks/tools/autoFormDetection/useAutoFormDetectionParameters";
|
||||
|
||||
@@ -34,23 +24,31 @@ function pdfFile(): File {
|
||||
return new File(["%PDF-1.4 dummy"], "doc.pdf", { type: "application/pdf" });
|
||||
}
|
||||
|
||||
function respond(headers: Record<string, string> = {}) {
|
||||
(apiClient.post as Mock).mockResolvedValue({
|
||||
data: new Blob(["%PDF-1.4 applied"]),
|
||||
headers,
|
||||
});
|
||||
}
|
||||
|
||||
const process = autoFormDetectionOperationConfig.customProcessor;
|
||||
|
||||
describe("processAutoFormDetection", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
(apiClient.post as Mock).mockResolvedValue({ data: { detections: [] } });
|
||||
respond();
|
||||
});
|
||||
|
||||
it("asks the server for fields, then applies them locally", async () => {
|
||||
it("asks the server to apply the fields and returns the PDF it sends back", async () => {
|
||||
const { files } = await process(defaultParameters, [pdfFile()]);
|
||||
|
||||
expect(apiClient.post).toHaveBeenCalledTimes(1);
|
||||
const [url, body] = (apiClient.post as Mock).mock.calls[0];
|
||||
const [url, body, config] = (apiClient.post as Mock).mock.calls[0];
|
||||
expect(url).toBe(DETECT_ENDPOINT);
|
||||
expect((body as FormData).get("applyToPdf")).toBe("false");
|
||||
expect(applyFields).toHaveBeenCalledTimes(1);
|
||||
expect((body as FormData).get("applyToPdf")).toBe("true");
|
||||
expect(config).toMatchObject({ responseType: "blob" });
|
||||
expect(files[0].name).toBe("doc_form.pdf");
|
||||
expect(files[0].type).toBe("application/pdf");
|
||||
});
|
||||
|
||||
it("sends the sensitivity's confidence threshold", async () => {
|
||||
@@ -60,31 +58,31 @@ describe("processAutoFormDetection", () => {
|
||||
expect(body.get("confThreshold")).toBe("0.45");
|
||||
});
|
||||
|
||||
it("does not retry when the detect request itself fails", async () => {
|
||||
(apiClient.post as Mock).mockRejectedValueOnce(new Error("503 no model"));
|
||||
it("publishes the summary the server reported", async () => {
|
||||
respond({
|
||||
"x-stirling-detected-fields":
|
||||
'{"total":11,"byType":{"text":8,"checkbox":3},"pagesWithFields":1}',
|
||||
});
|
||||
const seen: unknown[] = [];
|
||||
const stop = onSummary((s) => seen.push(s));
|
||||
|
||||
await expect(process(defaultParameters, [pdfFile()])).rejects.toThrow(
|
||||
"503 no model",
|
||||
);
|
||||
expect(apiClient.post).toHaveBeenCalledTimes(1);
|
||||
expect(applyFields).not.toHaveBeenCalled();
|
||||
await process(defaultParameters, [pdfFile()]);
|
||||
stop();
|
||||
|
||||
expect(seen).toEqual([
|
||||
{ total: 11, byType: { text: 8, checkbox: 3 }, pagesWithFields: 1 },
|
||||
]);
|
||||
});
|
||||
|
||||
it("re-requests a server-applied PDF when applying locally fails", async () => {
|
||||
expectConsole.warn(/applying fields locally failed/);
|
||||
(applyFields as Mock).mockRejectedValueOnce(new Error("bad xref"));
|
||||
(apiClient.post as Mock).mockResolvedValueOnce({
|
||||
data: { detections: [] },
|
||||
});
|
||||
(apiClient.post as Mock).mockResolvedValueOnce({
|
||||
data: new Blob(["%PDF-1.4 applied"]),
|
||||
});
|
||||
it("still returns the PDF when the summary header is missing or malformed", async () => {
|
||||
respond({ "x-stirling-detected-fields": "not json" });
|
||||
const seen: unknown[] = [];
|
||||
const stop = onSummary((s) => seen.push(s));
|
||||
|
||||
const { files } = await process(defaultParameters, [pdfFile()]);
|
||||
stop();
|
||||
|
||||
expect(apiClient.post).toHaveBeenCalledTimes(2);
|
||||
const body = (apiClient.post as Mock).mock.calls[1][1] as FormData;
|
||||
expect(body.get("applyToPdf")).toBe("true");
|
||||
expect(seen).toEqual([]);
|
||||
expect(files[0].name).toBe("doc_form.pdf");
|
||||
});
|
||||
});
|
||||
|
||||
+14
-45
@@ -9,9 +9,8 @@ import { createStandardErrorHandler } from "@app/utils/toolErrorHandler";
|
||||
import {
|
||||
emitStage,
|
||||
emitSummary,
|
||||
summarizeFields,
|
||||
parseSummary,
|
||||
} from "@app/services/formDetection/progress";
|
||||
import { DetectedField } from "@app/services/formDetection/types";
|
||||
import {
|
||||
AutoFormDetectionParameters,
|
||||
defaultParameters,
|
||||
@@ -23,11 +22,12 @@ const DETECT_ENDPOINT = "/api/v1/form/form-detection/detect";
|
||||
export const buildAutoFormDetectionFormData = (
|
||||
parameters: AutoFormDetectionParameters,
|
||||
file: File,
|
||||
applyToPdf: boolean,
|
||||
): FormData => {
|
||||
const formData = new FormData();
|
||||
formData.append("file", file);
|
||||
formData.append("applyToPdf", String(applyToPdf));
|
||||
// The server writes the AcroForm with PDFBox and reports what it added in a header, so one
|
||||
// request covers both the fillable PDF and the counts the results panel shows.
|
||||
formData.append("applyToPdf", "true");
|
||||
const confidence = resolveConfidence(parameters);
|
||||
if (typeof confidence === "number") {
|
||||
formData.append("confThreshold", String(confidence));
|
||||
@@ -35,29 +35,11 @@ export const buildAutoFormDetectionFormData = (
|
||||
return formData;
|
||||
};
|
||||
|
||||
function asPdf(data: BlobPart, source: File): File {
|
||||
function asPdf(data: Blob, source: File): File {
|
||||
const base = (source.name || "document").replace(/\.pdf$/i, "");
|
||||
return new File([data], `${base}_form.pdf`, { type: "application/pdf" });
|
||||
}
|
||||
|
||||
/**
|
||||
* The one place detection is asked for. A detector running in the browser would substitute here
|
||||
* without the caller changing, since the wire contract is plain geometry.
|
||||
*/
|
||||
export async function detectFields(
|
||||
parameters: AutoFormDetectionParameters,
|
||||
file: File,
|
||||
): Promise<DetectedField[]> {
|
||||
// Ask for the field list rather than a finished PDF so the summary panel has counts to show;
|
||||
// applying the fields here also spares the server a second parse of the same file.
|
||||
const res = await apiClient.post(
|
||||
DETECT_ENDPOINT,
|
||||
buildAutoFormDetectionFormData(parameters, file, false),
|
||||
);
|
||||
return ((res.data as { detections?: DetectedField[] })?.detections ??
|
||||
[]) as DetectedField[];
|
||||
}
|
||||
|
||||
async function processAutoFormDetection(
|
||||
parameters: AutoFormDetectionParameters,
|
||||
files: File[],
|
||||
@@ -67,31 +49,18 @@ async function processAutoFormDetection(
|
||||
try {
|
||||
emitStage({ kind: "starting" });
|
||||
emitStage({ kind: "uploading" });
|
||||
const fields = await detectFields(parameters, file);
|
||||
const res = await apiClient.post(
|
||||
DETECT_ENDPOINT,
|
||||
buildAutoFormDetectionFormData(parameters, file),
|
||||
{ responseType: "blob" },
|
||||
);
|
||||
|
||||
emitStage({ kind: "applying" });
|
||||
try {
|
||||
const { applyFields } =
|
||||
await import("@app/services/formDetection/applyFields");
|
||||
const bytes = await file.arrayBuffer();
|
||||
const appliedPdf = await applyFields(bytes, fields);
|
||||
|
||||
emitSummary(summarizeFields(fields));
|
||||
return { files: [asPdf(new Uint8Array(appliedPdf), file)] };
|
||||
} catch (e) {
|
||||
// Guards the local apply only: pdf-lib rejects some documents PDFBox accepts. A failed
|
||||
// detect must not land here, or every server error costs a second upload and inference.
|
||||
console.warn(
|
||||
"[AutoFormDetection] applying fields locally failed; asking the server to apply them",
|
||||
e,
|
||||
);
|
||||
const res = await apiClient.post(
|
||||
DETECT_ENDPOINT,
|
||||
buildAutoFormDetectionFormData(parameters, file, true),
|
||||
{ responseType: "blob" },
|
||||
);
|
||||
return { files: [asPdf(res.data as Blob, file)] };
|
||||
const summary = parseSummary(res.headers?.["x-stirling-detected-fields"]);
|
||||
if (summary) {
|
||||
emitSummary(summary);
|
||||
}
|
||||
return { files: [asPdf(res.data as Blob, file)] };
|
||||
} finally {
|
||||
emitStage({ kind: "done" });
|
||||
}
|
||||
|
||||
@@ -1,51 +0,0 @@
|
||||
// Turn detected fields into a real AcroForm with @cantoo/pdf-lib.
|
||||
// Rects arrive in PDF points, bottom-left origin - exactly what addToPage wants.
|
||||
|
||||
import { PDFDocument } from "@cantoo/pdf-lib";
|
||||
|
||||
import { DetectedField } from "@app/services/formDetection/types";
|
||||
|
||||
export async function applyFields(
|
||||
pdfBytes: ArrayBuffer | Uint8Array,
|
||||
fields: DetectedField[],
|
||||
): Promise<Uint8Array> {
|
||||
const pdfDoc = await PDFDocument.load(pdfBytes, {
|
||||
ignoreEncryption: true,
|
||||
throwOnInvalidObject: false,
|
||||
});
|
||||
const form = pdfDoc.getForm();
|
||||
const pages = pdfDoc.getPages();
|
||||
const counts: Record<string, number> = {};
|
||||
|
||||
for (const f of fields) {
|
||||
const page = pages[f.page];
|
||||
if (!page) continue;
|
||||
const r = f.rectInPdfPoints;
|
||||
if (r.w <= 0 || r.h <= 0) continue;
|
||||
|
||||
const kind =
|
||||
f.type === "checkbox"
|
||||
? "checkbox"
|
||||
: f.type === "signature"
|
||||
? "signature"
|
||||
: "text";
|
||||
counts[kind] = (counts[kind] ?? 0) + 1;
|
||||
const name = `${kind}_${f.page + 1}_${counts[kind]}`;
|
||||
|
||||
try {
|
||||
if (kind === "checkbox") {
|
||||
const cb = form.createCheckBox(name);
|
||||
cb.addToPage(page, { x: r.x, y: r.y, width: r.w, height: r.h });
|
||||
} else {
|
||||
// Signature and radio both land here: FormUtils.addFields coerces them to text too,
|
||||
// so a document is identical whichever side applied the fields.
|
||||
const tf = form.createTextField(name);
|
||||
tf.addToPage(page, { x: r.x, y: r.y, width: r.w, height: r.h });
|
||||
}
|
||||
} catch {
|
||||
// Skip a field that fails to add (e.g. a duplicate name) rather than abort the whole doc.
|
||||
}
|
||||
}
|
||||
|
||||
return pdfDoc.save();
|
||||
}
|
||||
@@ -1,5 +1,3 @@
|
||||
import { DetectedField } from "@app/services/formDetection/types";
|
||||
|
||||
export type DetectionStage =
|
||||
| { kind: "starting" }
|
||||
| { kind: "uploading" }
|
||||
@@ -36,16 +34,18 @@ export function onSummary(cb: (s: DetectionSummary) => void): () => void {
|
||||
return () => window.removeEventListener(SUMMARY_EVENT, handler);
|
||||
}
|
||||
|
||||
export function summarizeFields(fields: DetectedField[]): DetectionSummary {
|
||||
const byType: Record<string, number> = {};
|
||||
const pages = new Set<number>();
|
||||
for (const f of fields) {
|
||||
byType[f.type] = (byType[f.type] ?? 0) + 1;
|
||||
pages.add(f.page);
|
||||
/** Parse the server's X-Detected-Fields header; a missing or malformed one shows nothing. */
|
||||
export function parseSummary(header: unknown): DetectionSummary | null {
|
||||
if (typeof header !== "string" || header.length === 0) return null;
|
||||
try {
|
||||
const raw = JSON.parse(header) as Partial<DetectionSummary>;
|
||||
if (typeof raw.total !== "number") return null;
|
||||
return {
|
||||
total: raw.total,
|
||||
byType: raw.byType ?? {},
|
||||
pagesWithFields: raw.pagesWithFields ?? 0,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
total: fields.length,
|
||||
byType,
|
||||
pagesWithFields: pages.size,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
// Output schema for Auto Form Detection; mirrors the server /detect response.
|
||||
|
||||
export interface RectPt {
|
||||
x: number;
|
||||
y: number;
|
||||
w: number;
|
||||
h: number;
|
||||
}
|
||||
|
||||
export interface DetectedField {
|
||||
type: string;
|
||||
page: number;
|
||||
rectInPdfPoints: RectPt;
|
||||
confidence: number;
|
||||
}
|
||||
@@ -5,13 +5,21 @@ import {
|
||||
screen,
|
||||
waitFor,
|
||||
} from "@testing-library/react";
|
||||
import type React from "react";
|
||||
|
||||
import { MantineProvider } from "@mantine/core";
|
||||
import { HttpError } from "@portal/api/http";
|
||||
import { Integrations } from "@portal/views/Integrations";
|
||||
import type { IntegrationConfig } from "@portal/api/integrations";
|
||||
|
||||
// env="test" drops Mantine's transitions; otherwise a pending one fires after the environment
|
||||
// is torn down and vitest reports "window is not defined" against whichever file ran last.
|
||||
const TestProvider = ({ children }: { children: React.ReactNode }) => (
|
||||
<MantineProvider env="test">{children}</MantineProvider>
|
||||
);
|
||||
|
||||
const render = (ui: Parameters<typeof baseRender>[0]) =>
|
||||
baseRender(ui, { wrapper: MantineProvider });
|
||||
baseRender(ui, { wrapper: TestProvider });
|
||||
|
||||
vi.mock("react-i18next", () => ({
|
||||
useTranslation: () => ({
|
||||
|
||||
Reference in New Issue
Block a user