Skip to content
26 changes: 26 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,32 @@

All notable changes to this project will be documented in this file.

## [0.10.5] - 10/09/2026

- The Data Sandbox now shows how well a dataset follows the FAIR principles, the standard for whether research data
is Findable, Accessible, Interoperable and Reusable. The card sits under Files and links out to the principles
themselves for anyone meeting them for the first time.
- Everything the assessors reported is on the page, with nothing to unfold. All 15 FAIR criteria are listed, and
every individual check is shown, including the ones that passed. There is a filter for narrowing to what did not
pass, but it is off by default.
- Each criterion is quoted from the FAIR Guiding Principles (Wilkinson et al., 2016) and attributed, with a plainer
reading underneath it for anyone meeting FAIR for the first time. The plain reading is labelled as ours rather
than as part of the standard.
- Each score says how much evidence it rests on. A dataset can score 100% on Reusable from a single measured
criterion, which reads very differently from 100% across all three, and the card now makes that visible.
- Two assessors are reported next to each other, F-UJI and FAIR Champion, instead of being combined into one grade.
They often disagree about the same criterion, and a single number would hide which checks are actually in dispute.
- The card records when the assessment ran, which version of each assessor produced it, and whether it read published
or gateway metadata. A link opens the assessors' untouched output, so every figure on the card can be checked
against its source.
- A criterion that nothing could measure reads as "not assessed" rather than as a score of zero.
- The assessment does not run on its own. Opening the page only checks whether the dataset has been assessed before,
and shows that result straight away when it has, noting that it is an earlier one. Otherwise there is a "Check FAIR
score" button, since a fresh assessment takes upwards of a minute and most visits to the page are about launching a
tool rather than grading the data.
- Datasets without a DOI are assessed by F-UJI alone, and the card says so. FAIR Champion needs a resolvable
identifier and reports nothing without one, so it is skipped rather than left to time out.

## [0.10.4] - 09/09/2026

- A dataset cited in an AI answer is now a single control instead of two. The bracketed number and the dataset name
Expand Down
29 changes: 29 additions & 0 deletions server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,9 @@ const PORT = Number.parseInt(
process.env.PORT || (DEVELOPMENT ? "5173" : "3000"),
);
const SEARCH_API_URL = process.env.SEARCH_API_URL || "http://127.0.0.1:8000";
// FAIR assessment proxy (dans-labs/fair-assessment-proxy). Not hosted yet, so this
// points at the local docker-compose stack; set FAIR_API_URL once it is deployed.
const FAIR_API_URL = process.env.FAIR_API_URL || "http://127.0.0.1:8080";

// Express parses `?x=a&x=b` into an array and `?x[y]=a` into an object, so a
// query value is only a string once we have checked that it is. Everything else
Expand Down Expand Up @@ -112,6 +115,32 @@ app.use(
}),
);

// FAIR assessment proxy. Registered before `express.json()` for the same reason as
// `/auth`: the body parser would drain the request stream and POST /assessments
// would hang with no body ever reaching the service.
//
// Same-origin keeps the browser out of CORS entirely. The service does send
// `Access-Control-Allow-Origin: *`, so calling it directly works in dev, but that
// stops being true the moment it is hosted somewhere other than localhost.
app.use(
"/api/fair",
createProxyMiddleware({
target: FAIR_API_URL,
changeOrigin: true,
// `app.use` has already stripped the "/api/fair" mount prefix by the time the
// middleware runs, so the rewrite matches "^/" and prepends the service's own
// prefix — the same shape as the "/auth" proxy above. Matching "^/api/fair"
// here would never fire and every request would reach the service unprefixed.
pathRewrite: {"^/": "/api/v1/"},
on: {
error: (err, _req, res) => {
console.error("FAIR API proxy error:", err);
(res as express.Response).status(500).send("Proxy error");
},
},
}),
);

// TODO: rename to task/start
app.use(express.json());
app.post("/api/coordinator/start-task", async (req, res) => {
Expand Down
7 changes: 7 additions & 0 deletions src/components/SearchResultItem.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,13 @@ export const DatasetActions = ({hit, isLoggedIn = false, compact = false}: Datas
if (hit.title) {
params.set('title', hit.title);
}
// The FAIR assessment needs a persistent identifier, and `_id` is only
// sometimes a DOI. Prefer the real DOI, then the canonical URL, so the
// dataplayer can run both assessors instead of falling back to F-UJI alone.
const pid = hit._source?.doi || hit.dataset_url || hit._id;
if (pid) {
params.set('pid', pid);
}
// Preserve the search query for back navigation
const currentQuery = searchParams.get('q');
if (currentQuery) {
Expand Down
287 changes: 287 additions & 0 deletions src/components/dataplayer/FairAssessmentCard.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,287 @@
import {describe, it, expect, vi, beforeEach, afterEach} from "vitest";
import {render, screen, waitFor, within} from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import {http, HttpResponse} from "msw";
import {server} from "@/test/msw/server";
import {FairAssessmentCard} from "./FairAssessmentCard";
import type {AssessmentSummary, FairReport} from "@/types/fairTypes";

beforeEach(() => {
vi.spyOn(console, "error").mockImplementation(() => {
});
});
afterEach(() => {
vi.restoreAllMocks();
});

const DOI = "https://doi.org/10.1594/PANGAEA.908011";

const summary = (over: Partial<AssessmentSummary> = {}): AssessmentSummary => ({
id: "a1",
pid: "10.1594/PANGAEA.908011",
mode: "public",
assessors: ["fuji", "fair_champion"],
status: "completed",
created_at: "2026-09-09T09:52:27Z",
completed_at: "2026-09-09T09:52:50Z",
...over,
});

const report = (over: Partial<FairReport> = {}): FairReport => ({
id: "a1",
pid: "10.1594/PANGAEA.908011",
status: "completed",
cells: [
{cell: "f1", consensus: "pass", by_assessor: {fuji: "pass", fair_champion: "pass"}},
{cell: "f3", consensus: "fail", by_assessor: {fuji: "pass", fair_champion: "fail"}},
],
scores: {
fuji: {f: 100, a: null, i: 100, r: 83.3, overall: 94.4},
fair_champion: {f: 33.3, a: 100, i: 100, r: 100, overall: 83.3},
},
guidance: [
{
assessor: "fair_champion",
cell: "f3",
test: "MetadataIdentifierFound",
description: "The metadata contains an unambiguous reference to its own identifier",
message: "Unacceptable. The metadata does not contain its own identifier",
outcome: "fail",
guidance: ["Add the identifier to the metadata record."],
},
{
assessor: "fuji",
cell: "f1",
test: "FsF-F1-01D",
description: "Data is assigned a persistent identifier",
message: null,
outcome: "pass",
guidance: [],
},
],
...over,
});

/** No stored assessment: the card lands on its idle prompt. */
const noPreviousRuns = () =>
server.use(http.get("/api/fair/assessments/", () => HttpResponse.json([])));

/** A stored, finished assessment the card can show immediately. */
const withStoredReport = (over: Partial<FairReport> = {}) =>
server.use(
http.get("/api/fair/assessments/", () => HttpResponse.json([summary()])),
http.get("/api/fair/assessments/a1/report", () => HttpResponse.json(report(over))),
http.get("/api/fair/assessments/a1", () => HttpResponse.json({
...summary(),
results: [
{assessor: "fuji", assessor_version: "3.5.0"},
{assessor: "fair_champion", assessor_version: "0.5.8"},
],
})),
);

describe("FairAssessmentCard", () => {
it("renders nothing without a pid", () => {
const {container} = render(<FairAssessmentCard pid={null}/>);
expect(container).toBeEmptyDOMElement();
});

it("offers to run an assessment when the dataset has never been assessed", async () => {
noPreviousRuns();
render(<FairAssessmentCard pid={DOI}/>);
expect(await screen.findByRole("button", {name: /check fair score/i})).toBeInTheDocument();
});

it("does not assess on mount", async () => {
// A cold run costs 35s or more, so opening the dataplayer must not trigger one.
let posted = false;
server.use(
http.get("/api/fair/assessments/", () => HttpResponse.json([])),
http.post("/api/fair/assessments/", () => {
posted = true;
return HttpResponse.json({id: "new", status: "queued"});
}),
);

render(<FairAssessmentCard pid={DOI}/>);
await screen.findByRole("button", {name: /check fair score/i});
expect(posted).toBe(false);
});

it("shows a stored assessment straight away", async () => {
withStoredReport();
render(<FairAssessmentCard pid={DOI}/>);

expect(await screen.findByText("94.4% overall")).toBeInTheDocument();
expect(screen.getByText("83.3% overall")).toBeInTheDocument();
expect(screen.getByText(/showing an earlier assessment/i)).toBeInTheDocument();
});

it("shows an unmeasurable principle as not assessed rather than zero", async () => {
// F-UJI returns a: null routinely; rendering that as 0% would libel the dataset.
withStoredReport();
render(<FairAssessmentCard pid={DOI}/>);
const fuji = await screen.findByRole("group", {name: "F-UJI"});
expect(within(fuji).getByText("not assessed")).toBeInTheDocument();
});

it("warns that only F-UJI runs without a DOI", async () => {
noPreviousRuns();
render(<FairAssessmentCard pid="https://zenodo.org/records/3477090"/>);
expect(await screen.findByText(/no DOI, so only F-UJI/i)).toBeInTheDocument();
});

it("shows every check, passing ones included, with nothing to expand", async () => {
// The card reports on openness; hiding most of its own evidence behind a
// toggle would undercut the thing it measures.
withStoredReport();
render(<FairAssessmentCard pid={DOI}/>);

expect(await screen.findByText(/does not contain its own identifier/i)).toBeInTheDocument();
expect(screen.getByText("Add the identifier to the metadata record.")).toBeInTheDocument();
// The passing check is present too, not filtered away.
expect(screen.getByText("Data is assigned a persistent identifier")).toBeInTheDocument();
expect(screen.queryByRole("button", {name: /show details/i})).not.toBeInTheDocument();
});

it("counts the checks it is showing", async () => {
withStoredReport();
render(<FairAssessmentCard pid={DOI}/>);
expect(await screen.findByText(/2 checks, 1 not passing/)).toBeInTheDocument();
});

it("can narrow to failures on request, without that being the default", async () => {
withStoredReport();
render(<FairAssessmentCard pid={DOI}/>);

const filter = await screen.findByLabelText(/only show what did not pass/i);
expect(filter).not.toBeChecked();

await userEvent.click(filter);
expect(screen.queryByText("Data is assigned a persistent identifier")).not.toBeInTheDocument();
expect(screen.getByText(/does not contain its own identifier/i)).toBeInTheDocument();
});

it("states each assessor's verdict as text, not only on hover", async () => {
// Tooltips are unreachable by keyboard and screen reader, and invisible on
// touch, so the per-assessor verdicts live in a table instead.
withStoredReport();
render(<FairAssessmentCard pid={DOI}/>);

const table = await screen.findByRole("table");
const row = within(table).getByText("F3").closest("tr")!;
// F-UJI passed F3 while FAIR Champion failed it: both verdicts are readable.
expect(within(row).getByText("Pass")).toBeInTheDocument();
expect(within(row).getByText("Fail")).toBeInTheDocument();
});

it("quotes each criterion from the standard and attributes it", async () => {
withStoredReport();
render(<FairAssessmentCard pid={DOI}/>);
expect(await screen.findByText(/globally unique and persistent identifier/i)).toBeInTheDocument();
// Including criteria no assessor reported on, so their absence is visible.
expect(screen.getByText(/meet domain-relevant community standards/i)).toBeInTheDocument();
expect(screen.getByText(/Wilkinson et al., 2016/)).toBeInTheDocument();
expect(screen.getByRole("link", {name: /GO FAIR Foundation/i}))
.toHaveAttribute("href", "https://www.gofair.foundation/fair-principles");
});

it("marks the plain-language reading as ours, not the standard's", async () => {
withStoredReport();
render(<FairAssessmentCard pid={DOI}/>);
expect(await screen.findByText(/our plain-language reading of it, not part of the standard/i))
.toBeInTheDocument();
});

it("says how much evidence each score rests on", async () => {
withStoredReport();
render(<FairAssessmentCard pid={DOI}/>);
// F-UJI reported on F1 and F3 in this fixture; the other two went unmeasured.
const fuji = await screen.findByRole("group", {name: "F-UJI"});
expect(within(fuji).getByText(/from 2 of 4 criteria/)).toBeInTheDocument();
// The fixture has only F cells, so Accessible, Interoperable and Reusable each
// say nothing was measured rather than showing a 0%.
expect(within(fuji).getAllByText(/nothing measured of 3 criteria/)).toHaveLength(3);
});

it("shows when the assessment ran and which assessor versions produced it", async () => {
withStoredReport();
render(<FairAssessmentCard pid={DOI}/>);
expect(await screen.findByText(/F-UJI 3.5.0, FAIR Champion 0.5.8/)).toBeInTheDocument();
expect(screen.getByText(/Assessed 9 September 2026/)).toBeInTheDocument();
});

it("links to the untouched assessor output", async () => {
withStoredReport();
render(<FairAssessmentCard pid={DOI}/>);
const link = await screen.findByRole("link", {name: /full assessor output/i});
expect(link).toHaveAttribute("href", "/api/fair/assessments/a1/raw");
});

it("marks the derived criteria as not separately scored", async () => {
withStoredReport();
render(<FairAssessmentCard pid={DOI}/>);
// A1 and R1 are combined from their refinements, so counting them would
// count those refinements twice.
expect((await screen.findAllByText(/not scored separately/i)).length).toBe(2);
});

it("flags a partial result when one assessor failed", async () => {
withStoredReport({status: "completed_with_errors"});
render(<FairAssessmentCard pid={DOI}/>);
expect(await screen.findByText(/covers only the one that did/i)).toBeInTheDocument();
});

it("runs an assessment when asked and shows the result", async () => {
server.use(
http.get("/api/fair/assessments/", () => HttpResponse.json([])),
http.post("/api/fair/assessments/", () => HttpResponse.json({id: "new", status: "queued"})),
http.get("/api/fair/assessments/new", () =>
HttpResponse.json({
...summary({id: "new"}),
results: [{assessor: "fuji"}, {assessor: "fair_champion"}]
})),
http.get("/api/fair/assessments/new/report", () => HttpResponse.json(report({id: "new"}))),
);

render(<FairAssessmentCard pid={DOI}/>);
await userEvent.click(await screen.findByRole("button", {name: /check fair score/i}));

expect(await screen.findByText("94.4% overall")).toBeInTheDocument();
// A run in this session is not labelled as a stored one.
expect(screen.queryByText(/showing an earlier assessment/i)).not.toBeInTheDocument();
});

it("surfaces a failure with a way to retry", async () => {
server.use(
http.get("/api/fair/assessments/", () => HttpResponse.json([])),
http.post("/api/fair/assessments/", () => new HttpResponse(null, {status: 503})),
);

render(<FairAssessmentCard pid={DOI}/>);
await userEvent.click(await screen.findByRole("button", {name: /check fair score/i}));

expect(await screen.findByText(/not available right now/i)).toBeInTheDocument();
expect(screen.getByRole("button", {name: /try again/i})).toBeInTheDocument();
});

it("re-checks on request instead of reusing the stored result", async () => {
let postedCached: unknown;
withStoredReport();
server.use(
http.post("/api/fair/assessments/", async ({request}) => {
postedCached = ((await request.json()) as Record<string, unknown>).cached;
return HttpResponse.json({id: "new", status: "queued"});
}),
http.get("/api/fair/assessments/new", () =>
HttpResponse.json({...summary({id: "new"}), results: []})),
http.get("/api/fair/assessments/new/report", () => HttpResponse.json(report({id: "new"}))),
);

render(<FairAssessmentCard pid={DOI}/>);
await userEvent.click(await screen.findByRole("button", {name: /re-check/i}));

// Re-check must force a fresh run, not hand back what was already on screen.
await waitFor(() => expect(postedCached).toBe(false));
});
});
Loading
Loading