diff --git a/app/components/DragAndDrop.vue b/app/components/DragAndDrop.vue
index c8969e06..ed691c77 100644
--- a/app/components/DragAndDrop.vue
+++ b/app/components/DragAndDrop.vue
@@ -1,7 +1,8 @@
-
+
@@ -115,7 +179,15 @@ await missing_files();
+
diff --git a/app/components/TimeSeriesTargetSelector.vue b/app/components/TimeSeriesTargetSelector.vue
new file mode 100644
index 00000000..ffe150c1
--- /dev/null
+++ b/app/components/TimeSeriesTargetSelector.vue
@@ -0,0 +1,70 @@
+
+
+
+
+
+ No compatible model loaded. Import a model (BRep) first, then drop the time series again.
+
+
+
+
+
diff --git a/app/utils/import_workflow.ts b/app/utils/import_workflow.ts
index 63a136a0..c40d4674 100644
--- a/app/utils/import_workflow.ts
+++ b/app/utils/import_workflow.ts
@@ -150,4 +150,29 @@ async function importWorkflowFromSnapshot(items: readonly NewDataItem[]): Promis
});
return ids;
}
-export { importFile, importWorkflow, importWorkflowFromSnapshot, importItem };
+
+// The time series only adds attributes to the model, so the viewer object is reloaded under the same id with its stored style.
+async function applyTimeSeriesWorkflow(filename: string, id: string): Promise {
+ const backStore = useBackStore();
+ const dataStore = useDataStore();
+ const dataStyleStore = useDataStyleStore();
+ const hybridViewerStore = useHybridViewerStore();
+ await backStore.request({
+ schema: back_schemas.opengeodeweb_back.apply_time_series,
+ params: { id, filename },
+ });
+ const item = await dataStore.item(id);
+ await dataStore.deregisterObject(id);
+ await dataStore.registerObject(id, item.name ?? id);
+ await dataStyleStore.applyDefaultStyle(id);
+ await hybridViewerStore.remoteRender();
+ return id;
+}
+
+export {
+ importFile,
+ importWorkflow,
+ importWorkflowFromSnapshot,
+ importItem,
+ applyTimeSeriesWorkflow,
+};
diff --git a/app/utils/upload_path.ts b/app/utils/upload_path.ts
new file mode 100644
index 00000000..50d056a4
--- /dev/null
+++ b/app/utils/upload_path.ts
@@ -0,0 +1,75 @@
+// Files carry extra app-specific bookkeeping fields once picked up by the uploader.
+type UploadFile = File & { relativePath?: string; isConfigured?: boolean; displayName?: string };
+
+// Path of the file inside the back upload folder: keeps the folder tree of a dropped directory.
+function uploadPath(file: UploadFile): string {
+ for (const candidate of [file.relativePath, file.webkitRelativePath]) {
+ if (candidate !== undefined && candidate !== "") {
+ return candidate;
+ }
+ }
+ return file.name;
+}
+
+function fileExtension(path: string): string {
+ const name = path.slice(path.lastIndexOf("/") + 1);
+ const index = name.lastIndexOf(".");
+ return index === -1 ? "" : name.slice(index + 1).toLowerCase();
+}
+
+type FileMatch =
+ | { status: "matched"; indices: (number | undefined)[] }
+ | { status: "ambiguous"; path: string };
+
+// Number of folders the two paths have in common, in order: 0 when the file names differ.
+function matchScore(path: string, expected_path: string): number {
+ const segments = path.split("/");
+ const expected_segments = expected_path.split("/");
+ if (segments.at(-1) !== expected_segments.at(-1)) {
+ return 0;
+ }
+ // Longest common subsequence of the path segments
+ let previous = Array.from({ length: expected_segments.length + 1 }, () => 0);
+ for (const segment of segments) {
+ const current = [0];
+ for (const [index, expected_segment] of expected_segments.entries()) {
+ current.push(
+ segment === expected_segment
+ ? (previous[index] ?? 0) + 1
+ : Math.max(previous[index + 1] ?? 0, current[index] ?? 0),
+ );
+ }
+ previous = current;
+ }
+ return previous.at(-1) ?? 0;
+}
+
+// Finds each expected file in a selected folder, whatever the folder layout: a selected file is
+// Kept for an expected path when each is the other's closest match (most folders in common).
+// Gives, for each expected path, the index of its selected path (undefined when not found).
+function matchExpectedFiles(paths: readonly string[], expected: readonly string[]): FileMatch {
+ const scores = expected.map((expected_path) =>
+ paths.map((path) => matchScore(path, expected_path)),
+ );
+ const path_best_scores = paths.map((_path, index) =>
+ Math.max(0, ...scores.map((expected_scores) => expected_scores[index] ?? 0)),
+ );
+ const indices: (number | undefined)[] = [];
+ for (const [expected_index, expected_scores] of scores.entries()) {
+ const best = Math.max(0, ...expected_scores);
+ const candidates = expected_scores.flatMap((score, index) =>
+ best > 0 && score === best && path_best_scores[index] === best ? [index] : [],
+ );
+ if (candidates.length > 1) {
+ return { status: "ambiguous", path: expected[expected_index] ?? "" };
+ }
+ indices.push(candidates[0]);
+ }
+ return { status: "matched", indices };
+}
+
+// Shared by every drop zone that refuses folders.
+const foldersIgnoredWarning = "Folders can't be dropped here";
+
+export { fileExtension, foldersIgnoredWarning, matchExpectedFiles, uploadPath };
+export type { UploadFile };
diff --git a/internal/utils/upload_file.ts b/internal/utils/upload_file.ts
index da5d67da..b11b9fd1 100644
--- a/internal/utils/upload_file.ts
+++ b/internal/utils/upload_file.ts
@@ -3,6 +3,7 @@ import { CHUNK_SIZE_BYTES } from "@ogw_shared/utils/file.js";
import type { Microservice } from "./api_fetch.js";
import { consola } from "consola";
import { fetchRaw } from "@ogw_shared/utils/fetch_raw.js";
+import { uploadPath } from "@ogw_front/utils/upload_path";
import { useFeedbackStore } from "@ogw_front/stores/feedback.js";
interface UploadFileParams {
@@ -86,7 +87,7 @@ async function upload_file(
const chunk = file.slice(start, start + CHUNK_SIZE_BYTES);
const query = new URLSearchParams({
...params,
- filename: file.name,
+ filename: uploadPath(file),
chunk_index: String(chunkIndex),
total_chunks: String(totalChunks),
});
diff --git a/tests/unit/components/DragAndDrop.nuxt.test.ts b/tests/unit/components/DragAndDrop.nuxt.test.ts
index 6daf458a..86901eb6 100644
--- a/tests/unit/components/DragAndDrop.nuxt.test.ts
+++ b/tests/unit/components/DragAndDrop.nuxt.test.ts
@@ -7,6 +7,33 @@ import { mountSuspended } from "@nuxt/test-utils/runtime";
import { setupActivePinia, vuetify } from "@ogw_tests/utils";
import DragAndDrop from "@ogw_front/components/DragAndDrop.vue";
+function fileEntry(file: File): object {
+ return {
+ isFile: true,
+ isDirectory: false,
+ name: file.name,
+ file: (resolve: (value: File) => void): void => {
+ resolve(file);
+ },
+ };
+}
+
+function directoryEntry(name: string, children: unknown[]): object {
+ return {
+ isFile: false,
+ isDirectory: true,
+ name,
+ createReader: (): object => {
+ const batches = [children, []];
+ return {
+ readEntries: (resolve: (entries: unknown[]) => void): void => {
+ resolve(batches.shift() ?? []);
+ },
+ };
+ },
+ };
+}
+
describe("drag and drop", () => {
const pinia = setupActivePinia();
@@ -86,4 +113,86 @@ describe("drag and drop", () => {
expect(wrapper.emitted("files-selected")).toBeDefined();
expect(wrapper.emitted("files-selected")?.[0]?.[0]).toStrictEqual([dataFile]);
});
+
+ test("keeps the folder tree of a dropped directory", async () => {
+ const wrapper = await mountSuspended(DragAndDrop, {
+ global: { plugins: [vuetify, pinia] },
+ props: { accept: ".pvd", directory: true },
+ });
+ const nested = directoryEntry("vtkOutput", [fileEntry(new File(["vtu"], "rank_0.vtu"))]);
+ const folder = directoryEntry("spe10", [fileEntry(new File(["pvd"], "vtkOutput.pvd")), nested]);
+ const dropEvent = new Event("drop", { bubbles: true, cancelable: true });
+ Object.defineProperty(dropEvent, "dataTransfer", {
+ value: {
+ files: [],
+ types: ["Files"],
+ items: [{ webkitGetAsEntry: (): object => folder }],
+ },
+ });
+
+ globalThis.dispatchEvent(dropEvent);
+ await flushPromises();
+
+ expect(wrapper.emitted("files-selected")?.[0]?.[0]).toMatchObject([
+ { relativePath: "spe10/vtkOutput.pvd" },
+ { relativePath: "spe10/vtkOutput/rank_0.vtu" },
+ ]);
+ });
+
+ test("ignores dropped folders outside folder mode", async () => {
+ const wrapper = await mountSuspended(DragAndDrop, {
+ global: { plugins: [vuetify, pinia] },
+ props: { accept: "" },
+ });
+ const pvdFile = new File(["pvd"], "vtkOutput.pvd");
+ const folder = directoryEntry("vtkOutput", [fileEntry(new File(["vtu"], "rank_0.vtu"))]);
+ const dropEvent = new Event("drop", { bubbles: true, cancelable: true });
+ Object.defineProperty(dropEvent, "dataTransfer", {
+ value: {
+ files: [],
+ types: ["Files"],
+ items: [
+ { webkitGetAsEntry: (): object => folder },
+ { webkitGetAsEntry: (): object => fileEntry(pvdFile) },
+ ],
+ },
+ });
+
+ globalThis.dispatchEvent(dropEvent);
+ await flushPromises();
+
+ expect(wrapper.emitted("files-selected")?.[0]?.[0]).toStrictEqual([pvdFile]);
+ expect(wrapper.emitted("folders-ignored")).toHaveLength(1);
+ });
+
+ test("reports a dropped directory that cannot be read", async () => {
+ const wrapper = await mountSuspended(DragAndDrop, {
+ global: { plugins: [vuetify, pinia] },
+ props: { directory: true },
+ });
+ const folder = {
+ isFile: false,
+ isDirectory: true,
+ name: "broken",
+ createReader: (): object => ({
+ readEntries: (_resolve: unknown, reject: (error: Error) => void): void => {
+ reject(new Error("read failed"));
+ },
+ }),
+ };
+ const dropEvent = new Event("drop", { bubbles: true, cancelable: true });
+ Object.defineProperty(dropEvent, "dataTransfer", {
+ value: {
+ files: [],
+ types: ["Files"],
+ items: [{ webkitGetAsEntry: (): object => folder }],
+ },
+ });
+
+ globalThis.dispatchEvent(dropEvent);
+ await flushPromises();
+
+ expect(wrapper.emitted("files-selected")).toBeUndefined();
+ expect(wrapper.emitted("folder-read-error")).toStrictEqual([["read failed"]]);
+ });
});
diff --git a/tests/unit/components/FileSelector.nuxt.test.ts b/tests/unit/components/FileSelector.nuxt.test.ts
index dda45705..12341783 100644
--- a/tests/unit/components/FileSelector.nuxt.test.ts
+++ b/tests/unit/components/FileSelector.nuxt.test.ts
@@ -29,7 +29,8 @@ describe("file selector", () => {
registerEndpoint(allowed_files_schema.$id, {
method: toHTTPMethod(allowed_files_schema.methods[FIRST_INDEX]),
handler: () => ({
- extensions: ["1", "2", "3"],
+ data_extensions: ["1", "2", "3"],
+ time_series_extensions: [],
}),
});
const wrapper = await mountSuspended(FileSelector, {
@@ -71,7 +72,8 @@ describe("file selector", () => {
registerEndpoint(allowed_files_schema.$id, {
method: toHTTPMethod(allowed_files_schema.methods[FIRST_INDEX]),
handler: () => ({
- extensions: ["1", "2", "3"],
+ data_extensions: ["1", "2", "3"],
+ time_series_extensions: [],
}),
});
diff --git a/tests/unit/components/MissingFilesSelector.nuxt.test.ts b/tests/unit/components/MissingFilesSelector.nuxt.test.ts
index 016f8a7c..eba89c63 100644
--- a/tests/unit/components/MissingFilesSelector.nuxt.test.ts
+++ b/tests/unit/components/MissingFilesSelector.nuxt.test.ts
@@ -11,9 +11,12 @@ import schemas from "@geode/opengeodeweb-back/opengeodeweb_back_typed_schemas.js
// Local imports
import { mockAs, setupActivePinia, toHTTPMethod, vuetify } from "@ogw_tests/utils";
+import DragAndDrop from "@ogw_front/components/DragAndDrop.vue";
import FileUploader from "@ogw_front/components/FileUploader.vue";
import MissingFilesSelector from "@ogw_front/components/MissingFilesSelector.vue";
import { useBackStore } from "@ogw_front/stores/back";
+import { useFeedbackStore } from "@ogw_front/stores/feedback";
+import { uploadPath } from "@ogw_front/utils/upload_path";
const EXPECTED_LENGTH = 1;
const FIRST_INDEX = 0;
@@ -21,6 +24,10 @@ const SECOND_INDEX = 1;
const upload_file_schema = schemas.opengeodeweb_back.upload_file;
+function missing(files: string[]): object {
+ return { has_missing_files: files.length > 0, mandatory_files: files, additional_files: [] };
+}
+
describe("missing files selector", () => {
const pinia = setupActivePinia();
const backStore = useBackStore();
@@ -86,4 +93,54 @@ describe("missing files selector", () => {
});
expect(wrapper.emitted().increment_step).toHaveLength(EXPECTED_LENGTH);
});
+
+ test("time series uploads the referenced files found in a folder, level by level", async () => {
+ const request_mock = vi.fn();
+ request_mock
+ .mockResolvedValueOnce(missing(["outputs/a.vtm"]))
+ .mockResolvedValueOnce(missing(["outputs/a/b.vtu"]))
+ .mockResolvedValue(missing([]));
+ backStore.request = mockAs(request_mock);
+ const upload_mock = vi.fn().mockResolvedValue({});
+ backStore.upload = mockAs(upload_mock);
+
+ const wrapper = await mountSuspended(MissingFilesSelector, {
+ global: { plugins: [vuetify, pinia] },
+ props: { multiple: false, geodeObjectType: "BRep", filenames: ["a.pvd"], timeSeries: true },
+ });
+ const folder = [
+ Object.assign(new File(["b"], "b.vtu"), { relativePath: "run/outputs/a/b.vtu" }),
+ Object.assign(new File(["a"], "a.vtm"), { relativePath: "run/outputs/a.vtm" }),
+ Object.assign(new File(["m"], "model.og_brep"), { relativePath: "run/model.og_brep" }),
+ ];
+ wrapper.findComponent(DragAndDrop).vm.$emit("files-selected", folder);
+ await flushPromises();
+
+ expect(upload_mock.mock.calls.map(([file]) => uploadPath(file))).toStrictEqual([
+ "outputs/a.vtm",
+ "outputs/a/b.vtu",
+ ]);
+ expect(wrapper.emitted().increment_step).toHaveLength(EXPECTED_LENGTH);
+ });
+
+ test("time series warns when the folder lacks the missing files", async () => {
+ backStore.request = mockAs(
+ vi.fn().mockResolvedValue({
+ has_missing_files: true,
+ mandatory_files: ["a.vtm"],
+ additional_files: [],
+ }),
+ );
+ const warning_spy = vi.spyOn(useFeedbackStore(), "add_warning");
+
+ const wrapper = await mountSuspended(MissingFilesSelector, {
+ global: { plugins: [vuetify, pinia] },
+ props: { multiple: false, geodeObjectType: "BRep", filenames: ["a.pvd"], timeSeries: true },
+ });
+ wrapper.findComponent(DragAndDrop).vm.$emit("files-selected", [new File(["x"], "other.txt")]);
+ await flushPromises();
+
+ expect(warning_spy).toHaveBeenCalledWith("The selected folder does not contain: a.vtm");
+ expect(wrapper.emitted().increment_step).toBeUndefined();
+ });
});
diff --git a/tests/unit/components/TimeSeriesTargetSelector.nuxt.test.ts b/tests/unit/components/TimeSeriesTargetSelector.nuxt.test.ts
new file mode 100644
index 00000000..b36cfa60
--- /dev/null
+++ b/tests/unit/components/TimeSeriesTargetSelector.nuxt.test.ts
@@ -0,0 +1,55 @@
+// oxlint-disable vitest/require-mock-type-parameters
+// Third party imports
+import { describe, expect, test, vi } from "vitest";
+import { flushPromises } from "@vue/test-utils";
+import { mountSuspended } from "@nuxt/test-utils/runtime";
+
+// Local imports
+import { mockAs, setupActivePinia, vuetify } from "@ogw_tests/utils";
+import TimeSeriesTargetSelector from "@ogw_front/components/TimeSeriesTargetSelector.vue";
+import { useBackStore } from "@ogw_front/stores/back";
+import { useDataStore } from "@ogw_front/stores/data";
+
+describe("time series target selector", () => {
+ const pinia = setupActivePinia();
+ const backStore = useBackStore();
+ backStore.request = mockAs(
+ vi.fn().mockResolvedValue({ allowed_objects: ["BRep"] }),
+ );
+
+ test("lists compatible models and emits the selected one", async () => {
+ const dataStore = useDataStore(pinia);
+ dataStore.allItems = mockAs(
+ vi.fn().mockResolvedValue([
+ { id: "brep_id", name: "cube", geode_object_type: "BRep" },
+ { id: "points_id", name: "points", geode_object_type: "PointSet3D" },
+ ]),
+ );
+ const wrapper = await mountSuspended(TimeSeriesTargetSelector, {
+ global: { plugins: [vuetify, pinia] },
+ props: { filename: "time_series.pvd" },
+ });
+ await flushPromises();
+
+ const items = wrapper.findAll("[data-testid='timeSeriesTarget']");
+ expect(items).toHaveLength(1);
+ await items[0]?.trigger("click");
+
+ expect(wrapper.emitted("update_values")?.[0]).toStrictEqual([
+ { target_id: "brep_id", geode_object_type: "BRep" },
+ ]);
+ expect(wrapper.emitted("increment_step")).toBeDefined();
+ });
+
+ test("warns when no compatible model is loaded", async () => {
+ const dataStore = useDataStore(pinia);
+ dataStore.allItems = mockAs(vi.fn().mockResolvedValue([]));
+ const wrapper = await mountSuspended(TimeSeriesTargetSelector, {
+ global: { plugins: [vuetify, pinia] },
+ props: { filename: "time_series.pvd" },
+ });
+ await flushPromises();
+
+ expect(wrapper.find("[data-testid='noTimeSeriesTarget']").exists()).toBe(true);
+ });
+});
diff --git a/tests/unit/utils/import_workflow.nuxt.test.ts b/tests/unit/utils/import_workflow.nuxt.test.ts
new file mode 100644
index 00000000..8c8e127b
--- /dev/null
+++ b/tests/unit/utils/import_workflow.nuxt.test.ts
@@ -0,0 +1,77 @@
+// oxlint-disable vitest/require-mock-type-parameters
+// Third party imports
+import { type Mock, describe, expect, test, vi } from "vitest";
+
+// Local imports
+import { mockAs, setupActivePinia } from "@ogw_tests/utils";
+import { applyTimeSeriesWorkflow } from "@ogw_front/utils/import_workflow";
+import { useBackStore } from "@ogw_front/stores/back";
+import { useDataStore } from "@ogw_front/stores/data";
+import { useDataStyleStore } from "@ogw_front/stores/data_style";
+import { useHybridViewerStore } from "@ogw_front/stores/hybrid_viewer";
+
+function setup(): {
+ calls: string[];
+ request: Mock;
+ applyDefaultStyle: Mock;
+ addDataStyle: Mock;
+ remoteRender: Mock;
+} {
+ const calls: string[] = [];
+ const backStore = useBackStore();
+ const dataStore = useDataStore();
+ const dataStyleStore = useDataStyleStore();
+ const hybridViewerStore = useHybridViewerStore();
+ const request = vi.fn().mockResolvedValue({ id: "brep_id", viewer_type: "model" });
+ const applyDefaultStyle = vi.fn().mockImplementation(() => {
+ calls.push("style");
+ return [];
+ });
+ const addDataStyle = vi.fn();
+ const remoteRender = vi.fn();
+ backStore.request = mockAs(request);
+ dataStore.item = mockAs(
+ vi.fn().mockResolvedValue({ id: "brep_id", name: "cube" }),
+ );
+ dataStore.deregisterObject = mockAs(
+ vi.fn().mockImplementation(() => {
+ calls.push("deregister");
+ }),
+ );
+ dataStore.registerObject = mockAs(
+ vi.fn().mockImplementation(() => {
+ calls.push("register");
+ }),
+ );
+ dataStyleStore.applyDefaultStyle =
+ mockAs(applyDefaultStyle);
+ dataStyleStore.addDataStyle = mockAs(addDataStyle);
+ hybridViewerStore.remoteRender = mockAs(remoteRender);
+ return { calls, request, applyDefaultStyle, addDataStyle, remoteRender };
+}
+
+describe("apply time series workflow", () => {
+ setupActivePinia();
+
+ test("keeps the id and re-applies the stored style", async () => {
+ const { calls, request, applyDefaultStyle, remoteRender } = setup();
+
+ const id = await applyTimeSeriesWorkflow("time_series.pvd", "brep_id");
+
+ expect(id).toBe("brep_id");
+ expect(request).toHaveBeenCalledWith(
+ expect.objectContaining({ params: { id: "brep_id", filename: "time_series.pvd" } }),
+ );
+ expect(calls).toStrictEqual(["deregister", "register", "style"]);
+ expect(applyDefaultStyle).toHaveBeenCalledWith("brep_id");
+ expect(remoteRender).toHaveBeenCalledWith();
+ });
+
+ test("does not reset the style", async () => {
+ const { addDataStyle } = setup();
+
+ await applyTimeSeriesWorkflow("time_series.pvd", "brep_id");
+
+ expect(addDataStyle).not.toHaveBeenCalled();
+ });
+});
diff --git a/tests/unit/utils/upload_path.nuxt.test.ts b/tests/unit/utils/upload_path.nuxt.test.ts
new file mode 100644
index 00000000..3fab9648
--- /dev/null
+++ b/tests/unit/utils/upload_path.nuxt.test.ts
@@ -0,0 +1,67 @@
+// Third party imports
+import { describe, expect, test } from "vitest";
+
+// Local imports
+import { fileExtension, matchExpectedFiles, uploadPath } from "@ogw_front/utils/upload_path";
+
+describe("upload path", () => {
+ test("uses the relative path of a file dropped from a folder", () => {
+ const file = Object.assign(new File(["vtu"], "rank_0.vtu"), {
+ relativePath: "vtkOutput/000000/rank_0.vtu",
+ });
+ expect(uploadPath(file)).toBe("vtkOutput/000000/rank_0.vtu");
+ });
+
+ test("uses the name of a loose file", () => {
+ expect(uploadPath(new File(["pvd"], "time_series.pvd"))).toBe("time_series.pvd");
+ });
+
+ test("gets the lower case extension", () => {
+ expect(fileExtension("spe10/vtkOutput.PVD")).toBe("pvd");
+ });
+
+ describe("match expected files", () => {
+ const expected = [
+ "outputs/vtkOutput/000000.vtm",
+ "outputs/vtkOutput/000000/block/rank_0.vtu",
+ "outputs/vtkOutput/000001/block/rank_0.vtu",
+ ];
+
+ test("finds the files in a folder above them", () => {
+ const selected = [
+ "exports/model.og_brep",
+ "exports/outputs/vtkOutput/000001/block/rank_0.vtu",
+ "exports/outputs/vtkOutput/000000/block/rank_0.vtu",
+ "exports/outputs/vtkOutput/000000.vtm",
+ ];
+ expect(matchExpectedFiles(selected, expected)).toStrictEqual({
+ status: "matched",
+ indices: [3, 2, 1],
+ });
+ });
+
+ test("finds the files in a folder inside the referenced tree", () => {
+ const selected = ["vtkOutput/000000.vtm", "vtkOutput/000000/block/rank_0.vtu"];
+ expect(matchExpectedFiles(selected, expected)).toStrictEqual({
+ status: "matched",
+ indices: [0, 1, undefined],
+ });
+ });
+
+ test("finds the files in a different layout", () => {
+ const selected = ["run/000001/rank_0.vtu", "run/steps/000000.vtm", "run/000000/rank_0.vtu"];
+ expect(matchExpectedFiles(selected, expected)).toStrictEqual({
+ status: "matched",
+ indices: [1, 2, 0],
+ });
+ });
+
+ test("reports several equally matching files", () => {
+ const selected = ["run1/vtkOutput/000000.vtm", "run2/vtkOutput/000000.vtm"];
+ expect(matchExpectedFiles(selected, expected)).toStrictEqual({
+ status: "ambiguous",
+ path: "outputs/vtkOutput/000000.vtm",
+ });
+ });
+ });
+});