+
diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte
index a626766b..1a92b843 100644
--- a/src/routes/+page.svelte
+++ b/src/routes/+page.svelte
@@ -1,8 +1,11 @@
@@ -10,3 +13,17 @@
+
+
+
+
+ {#snippet listItemComponent(componentData)}
+
+ {/snippet}
+ {#snippet listItemSkeleton()}
+
+ {/snippet}
+
+
diff --git a/src/routes/+page.ts b/src/routes/+page.ts
new file mode 100644
index 00000000..abb0b53a
--- /dev/null
+++ b/src/routes/+page.ts
@@ -0,0 +1,123 @@
+import type { PageLoad } from "./$types";
+import { BackendController } from "$lib/controller/backend-controller";
+import {
+ type Paper,
+ type Project,
+ type ProjectMetadata,
+ type StageEntry,
+ type User,
+} from "$lib/model/backend";
+import { calculateStageProgress } from "$lib/utils/statistics-helper";
+
+// import type { Project, ProjectMetadata } from "$lib/model/backend";
+
+async function requestProjectMetadata(project: Project): Promise
{
+ const members: User[] = await BackendController.getInstance().project(project.id).getMembers();
+ const currentStage: number = await BackendController.getInstance()
+ .project(project.id)
+ .getStageCount();
+ const allPapersInCurrentStage: StageEntry[] = await BackendController.getInstance()
+ .project(project.id)
+ .stage(currentStage)
+ .getPapers();
+
+ return {
+ project: project,
+ members: members,
+ stage: currentStage,
+ stageProgress: calculateStageProgress(
+ allPapersInCurrentStage.map((stageEntry: StageEntry): Paper => stageEntry.paper),
+ ),
+ };
+}
+
+/**
+ * Loads projects for the user logged in.
+ *
+ * Therefore, request the project ids of the projects, the user logged in is member of and use
+ * these ids to request:
+ *
+ * - the project members
+ * - the current project stage
+ * - the progress of the current stage
+ *
+ *
+ * TODO: check, whether this can be handled with a single request, e.g. on route /projects/[id]/projectMetadata/.
+ */
+export const load: PageLoad = () => {
+ const projectMetadata = BackendController.getInstance()
+ .thisUser()
+ .getAllProjects()
+ .then(async (projects: Project[]) => {
+ try {
+ return await Promise.all(
+ projects.map((project: Project) => requestProjectMetadata(project)),
+ );
+ } catch {
+ throw new Error("Could not load project details.");
+ }
+ })
+ .catch(() => {
+ throw new Error("Could not load projects.");
+ });
+
+ // attach noop-catch to handle promise rejection correctly (see https://svelte.dev/docs/kit/load#Streaming-with-promises)
+ projectMetadata.catch(() => {});
+
+ return { projectMetadata };
+
+ // uncomment the following lines, if you want to test the projects list with a lot of projects
+ /* const Users = {
+ johnDoe: {
+ id: 0,
+ firstName: "John",
+ lastName: "Doe",
+ email: "john.doe@example.com",
+ isAdmin: true,
+ status: "active",
+ },
+ janeDoe: {
+ id: 1,
+ firstName: "Jane",
+ lastName: "Doe",
+ email: "jane.doe@example.com",
+ isAdmin: false,
+ status: "active",
+ },
+ };
+
+ function createProject(project: Partial): Project {
+ return {
+ id: 0,
+ name: "Foo",
+ reviewDecisionMatrix: {
+ numberOfReviewers: 2,
+ patterns: new Map(),
+ },
+ similarityThreshold: 0.7,
+ paperFetchApis: ["bar"],
+ ...project,
+ };
+ }
+
+ const projectMetadataExamples: ProjectMetadata[] = Array(20)
+ .fill(0)
+ .map((_, i) => {
+ return {
+ project: createProject({ id: i, name: `Demo Project ${i}` }),
+ members: [Users.johnDoe, Users.janeDoe],
+ stage: 1,
+ stageProgress: Math.random() * 100,
+ };
+ });
+
+ const projectMetadata: Promise = new Promise((resolve, reject) =>
+ setTimeout(() => {
+ // resolve(projectMetadataExamples);
+ reject(new Error("Bla bla"));
+ }, 2000),
+ );
+ */
+
+ // return { projectMetadata };
+};
diff --git a/src/routes/project/[projectId]/+layout.ts b/src/routes/project/[projectId]/+layout.ts
index b3a5047d..98afd8b2 100644
--- a/src/routes/project/[projectId]/+layout.ts
+++ b/src/routes/project/[projectId]/+layout.ts
@@ -15,6 +15,7 @@ export const load: LayoutLoad = async ({ params }) => {
},
similarityThreshold: 0,
paperFetchApis: [],
+ archived: false,
};
return {
project,
diff --git a/tests/integration/project-components/project-list-entry-skeleton.test.ts b/tests/integration/project-components/project-list-entry-skeleton.test.ts
new file mode 100644
index 00000000..ebc1283e
--- /dev/null
+++ b/tests/integration/project-components/project-list-entry-skeleton.test.ts
@@ -0,0 +1,24 @@
+import { expect, test, describe } from "vitest";
+import ProjectListEntrySkeleton from "$lib/components/composites/project-components/ProjectListEntrySkeleton.svelte";
+import { render, screen } from "@testing-library/svelte";
+
+describe("ProjectListEntrySkeletonComponent", () => {
+ test("When project list entry skeleton is rendered, then no data are displayed but only skeletons.", () => {
+ render(ProjectListEntrySkeleton);
+
+ // expect skeleton for project name, members and stage, but not progress
+ screen
+ .getAllByTestId("skeleton", { exact: false })
+ .forEach((skeleton) => expect(skeleton).toBeInTheDocument());
+ expect(screen.getAllByTestId("skeleton", { exact: false }).length).toBe(3);
+
+ expect(screen.getByTestId("project-stage-progress")).toBeInTheDocument();
+ expect(screen.getByTestId("project-stage-progress")).toHaveValue(0);
+ });
+
+ test("When project list entry skeleton is rendered, then the project list entry has no behavior.", () => {
+ render(ProjectListEntrySkeleton);
+
+ expect(screen.queryByRole("button")).not.toBeInTheDocument();
+ });
+});
diff --git a/tests/integration/project-components/project-list-entry.test.ts b/tests/integration/project-components/project-list-entry.test.ts
new file mode 100644
index 00000000..0f3f8776
--- /dev/null
+++ b/tests/integration/project-components/project-list-entry.test.ts
@@ -0,0 +1,66 @@
+import { expect, test, describe } from "vitest";
+import ProjectListEntry from "$lib/components/composites/project-components/ProjectListEntry.svelte";
+import { render, screen } from "@testing-library/svelte";
+import { createProject, Users } from "../../model-builder";
+
+describe("ProjectListEntryComponent", () => {
+ test("When all required props are provided, then the project list entry is completely shown.", () => {
+ render(ProjectListEntry, {
+ props: {
+ project: createProject({
+ name: "Demo Project",
+ }),
+ members: [Users.johnDoe, Users.janeDoe],
+ stage: 1,
+ stageProgress: 60,
+ },
+ });
+
+ // Project information are shown directly
+ expect(screen.getByText("Demo Project")).toBeInTheDocument();
+ expect(screen.getByText("John Doe, Jane Doe")).toBeInTheDocument();
+ expect(screen.getByText("Stage 1")).toBeInTheDocument();
+
+ // Project (stage) progress is visualized using a progress bar
+ expect(screen.queryByText("60")).not.toBeInTheDocument();
+ expect(screen.queryByTestId("stage-progress-bar")).toHaveValue(60);
+ });
+
+ test("When all no members are provided, then the list entry shows a hint.", () => {
+ render(ProjectListEntry, {
+ props: {
+ project: createProject({
+ name: "Demo Project",
+ }),
+ members: [],
+ stage: 1,
+ stageProgress: 60,
+ },
+ });
+
+ expect(screen.getByText("Demo Project")).toBeInTheDocument();
+ expect(screen.getByText("no members")).toBeInTheDocument();
+ expect(screen.getByText("Stage 1")).toBeInTheDocument();
+ expect(screen.queryByText("60")).not.toBeInTheDocument();
+ });
+
+ test("When project is archived, then the list entry is opaque.", () => {
+ render(ProjectListEntry, {
+ props: {
+ project: createProject({
+ name: "Demo Project",
+ archived: true,
+ }),
+ members: [],
+ stage: 1,
+ stageProgress: 60,
+ },
+ });
+
+ expect(screen.getByText("Demo Project")).toBeInTheDocument();
+ expect(screen.getByText("no members")).toBeInTheDocument();
+ expect(screen.getByText("Stage 1")).toBeInTheDocument();
+
+ expect(screen.getByRole("button")).toHaveClass("opacity-25");
+ });
+});
diff --git a/tests/integration/utils/ExampleListItem.svelte b/tests/integration/utils/ExampleListItem.svelte
new file mode 100644
index 00000000..dfd564b4
--- /dev/null
+++ b/tests/integration/utils/ExampleListItem.svelte
@@ -0,0 +1,5 @@
+
+
+{content}
diff --git a/tests/integration/utils/ExampleListItemSkeleton.svelte b/tests/integration/utils/ExampleListItemSkeleton.svelte
new file mode 100644
index 00000000..9a93fef7
--- /dev/null
+++ b/tests/integration/utils/ExampleListItemSkeleton.svelte
@@ -0,0 +1,5 @@
+
+
+
diff --git a/tests/integration/utils/ExampleSnippets.svelte b/tests/integration/utils/ExampleSnippets.svelte
new file mode 100644
index 00000000..4f3f2f06
--- /dev/null
+++ b/tests/integration/utils/ExampleSnippets.svelte
@@ -0,0 +1,14 @@
+
+
+{#snippet listItemComponent(componentData)}
+
+{/snippet}
+
+{#snippet listItemSkeleton()}
+
+{/snippet}
diff --git a/tests/integration/utils/named-list.test.ts b/tests/integration/utils/named-list.test.ts
new file mode 100644
index 00000000..2ace161b
--- /dev/null
+++ b/tests/integration/utils/named-list.test.ts
@@ -0,0 +1,120 @@
+import { expect, test, describe } from "vitest";
+import { render, screen } from "@testing-library/svelte";
+import NamedList from "$lib/components/composites/utils/NamedList.svelte";
+// @ts-expect-error "Snippets have implicetly type any"
+import { listItemComponent, listItemSkeleton } from "./ExampleSnippets.svelte";
+
+describe("NamedListComponent", () => {
+ test("When all required props are provided, then the named list is completely shown.", async () => {
+ const componentData: Promise = Promise.resolve(
+ Array.from({ length: 15 }, (_, i) => `Hello world ${i}`),
+ );
+
+ render(NamedList, {
+ props: {
+ listName: "Test List",
+ items: componentData,
+ listItemComponent: listItemComponent,
+ listItemSkeleton: listItemSkeleton,
+ },
+ });
+
+ // List title is shown
+ expect(screen.getByText("Test List")).toBeInTheDocument();
+
+ setTimeout(() => {
+ // 15 list items are displayed
+ expect(screen.getAllByRole("span").length).toBe(15);
+
+ // list has an overflow, as not all items can be displayed
+ expect(
+ screen.getByRole("list").scrollHeight > screen.getByRole("list").clientHeight,
+ ).toBeTruthy();
+ }, 250);
+ });
+
+ test("When the number of items should be shown, then the name of the list is extended by the number of list items.", async () => {
+ const componentData: Promise = Promise.resolve(
+ Array.from({ length: 5 }, (_, i) => `Hello world ${i}`),
+ );
+
+ render(NamedList, {
+ props: {
+ listName: "Test List",
+ items: componentData,
+ listItemComponent: listItemComponent,
+ listItemSkeleton: listItemSkeleton,
+ showNumberOfListItems: true,
+ },
+ });
+
+ setTimeout(() => {
+ // List title is shown
+ expect(screen.getByText("Test List (5)")).toBeInTheDocument();
+ }, 250);
+ });
+
+ test("When the number of items should be shown and is given explicitly, then the name of the list is extended by the given number of list items.", async () => {
+ const componentData: Promise = Promise.resolve(
+ Array.from({ length: 5 }, (_, i) => `Hello world ${i}`),
+ );
+
+ render(NamedList, {
+ props: {
+ listName: "Test List",
+ items: componentData,
+ listItemComponent: listItemComponent,
+ listItemSkeleton: listItemSkeleton,
+ showNumberOfListItems: true,
+ numberOfItems: 10,
+ },
+ });
+
+ setTimeout(() => {
+ // List title is shown
+ expect(screen.getByText("Test List (10)")).toBeInTheDocument();
+ }, 250);
+ });
+
+ test("When the list is loading, then skeleton elements are shown", async () => {
+ const componentData: Promise = new Promise((resolve) =>
+ setTimeout(() => resolve(Array.from({ length: 5 }, (_, i) => `Hello world ${i}`)), 100),
+ );
+
+ render(NamedList, {
+ props: {
+ listName: "Test List",
+ items: componentData,
+ listItemComponent: listItemComponent,
+ listItemSkeleton: listItemSkeleton,
+ },
+ });
+
+ expect(screen.queryAllByRole("span").length).toBe(0);
+ expect(screen.queryAllByTestId("skeleton").length).toBe(10);
+ });
+
+ test("When the list item could not be loaded, then the error message is shown", async () => {
+ const componentData: Promise = new Promise(() =>
+ setTimeout(() => {
+ throw new Error("Test Error");
+ }, 100),
+ );
+
+ render(NamedList, {
+ props: {
+ listName: "Test List",
+ items: componentData,
+ listItemComponent: listItemComponent,
+ listItemSkeleton: listItemSkeleton,
+ },
+ });
+
+ setTimeout(() => {
+ expect(screen.queryAllByRole("span").length).toBe(0);
+ expect(screen.queryAllByTestId("skeleton").length).toBe(0);
+
+ expect(screen.getByText("Test Error"));
+ }, 250);
+ });
+});
diff --git a/tests/model-builder.ts b/tests/model-builder.ts
index af029578..ff3d7c6c 100644
--- a/tests/model-builder.ts
+++ b/tests/model-builder.ts
@@ -47,6 +47,7 @@ export function createProject(project: Partial): Project {
},
similarityThreshold: 0.7,
paperFetchApis: ["bar"],
+ archived: false,
...project,
};
}
diff --git a/tests/unit/demo.test.ts b/tests/unit/demo.test.ts
deleted file mode 100644
index eb835451..00000000
--- a/tests/unit/demo.test.ts
+++ /dev/null
@@ -1,7 +0,0 @@
-import { describe, it, expect } from "vitest";
-
-describe("sum test", () => {
- it("adds 1 + 2 to equal 3", () => {
- expect(1 + 2).toBe(3);
- });
-});
diff --git a/tests/unit/helpers/stage-progress-calculator.test.ts b/tests/unit/helpers/stage-progress-calculator.test.ts
new file mode 100644
index 00000000..f2d12d1d
--- /dev/null
+++ b/tests/unit/helpers/stage-progress-calculator.test.ts
@@ -0,0 +1,49 @@
+import { describe, it, expect } from "vitest";
+import { calculateStageProgress } from "$lib/utils/statistics-helper";
+import { type Paper, ReviewDecision } from "$lib/model/backend";
+import { createPaper } from "../../model-builder";
+
+describe("StageProgressCalculator", () => {
+ it("When no paper are provided, then the progress is zero", () => {
+ const papers: Paper[] = [];
+
+ expect(calculateStageProgress(papers)).toBe(0);
+ });
+
+ it("When all paper are not reviewed yet, then the progress is zero", () => {
+ const papers: Paper[] = Array.from({ length: 4 }, (_, i) =>
+ createPaper({ id: i, reviewData: undefined }),
+ );
+
+ expect(calculateStageProgress(papers)).toBe(0);
+ });
+
+ it("When one paper is accepted, one declined, one maybe and one unreviewed, then the progress is 50%", () => {
+ const decisions = [
+ undefined,
+ { finalDecision: ReviewDecision.Maybe, reviews: [] },
+ { finalDecision: ReviewDecision.Accepted, reviews: [] },
+ { finalDecision: ReviewDecision.Declined, reviews: [] },
+ ];
+
+ const papers: Paper[] = Array.from({ length: 4 }, (_, i) =>
+ createPaper({ id: i, reviewData: decisions[i] }),
+ );
+
+ expect(calculateStageProgress(papers)).toBe(50);
+ });
+
+ it("When all papers are decided, then the progress is 100%", () => {
+ const decisions = [
+ { finalDecision: ReviewDecision.Declined, reviews: [] },
+ { finalDecision: ReviewDecision.Accepted, reviews: [] },
+ { finalDecision: ReviewDecision.Declined, reviews: [] },
+ ];
+
+ const papers: Paper[] = Array.from({ length: 3 }, (_, i) =>
+ createPaper({ id: i, reviewData: decisions[i] }),
+ );
+
+ expect(calculateStageProgress(papers)).toBe(100);
+ });
+});
diff --git a/vite.config.ts b/vite.config.ts
index 6fba265d..63c83cdb 100644
--- a/vite.config.ts
+++ b/vite.config.ts
@@ -15,7 +15,11 @@ export default defineConfig({
setupFiles: ["./tests/setupTest.ts"],
coverage: {
include: ["src/**"],
- exclude: ["src/routes/**/+*.{svelte,ts}", "**/*.d.ts"],
+ exclude: [
+ "src/routes/**/+*.{svelte,ts}",
+ "**/*.d.ts",
+ "src/lib/components/primitives/**",
+ ],
provider: "v8",
enabled: true,
cleanOnRerun: true,