Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions integration_tests/pages/ProjectListPage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,13 @@ export class ProjectListPage extends BasePage {
// the rendering-noise filter: part of each library's config section
noiseFilter = this.dialog.getByLabel("Ignore rendering noise");
noiseThreshold = this.dialog.locator("#blurAmplitudeThreshold");
// DINOv2's second look, offered under the filter while it is on
dinov2Check = this.dialog.getByLabel("Check with DINOv2");
// scrolled to before the switch is used: scrolling to its input, which
// overhangs the switch, shifts the switch inside its clipped box
dinov2CheckLabel = this.dialog.getByText("Check with DINOv2");
dinov2Threshold = this.dialog.locator("#dinov2Threshold");
dinov2Status = this.dialog.locator("#dinov2Status");

// the engine's own fields, below the library select
vlmProvider = this.dialog.getByRole("combobox", { name: "VLM Provider" });
Expand Down
62 changes: 62 additions & 0 deletions integration_tests/test/projectList.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { ImageComparison } from "~client/types/imageComparison";
import {
API_URL,
mockDeleteProject,
mockDinov2Status,
mockGetProjects,
mockSaveProject,
} from "utils/mocks";
Expand Down Expand Up @@ -327,3 +328,64 @@ test("opens a project with the filter on as it was saved", async ({
await expect(projectListPage.noiseFilter).toBeChecked();
await expect(projectListPage.noiseThreshold).toHaveValue("42");
});

test.describe("DINOv2 check", () => {
test("is saved with its threshold where the server has the service", async ({
projectListPage,
page,
}) => {
await mockDinov2Status(page, { configured: true, available: true });
await mockSaveProject(page);

await projectListPage.editBtn.click();
await projectListPage.noiseFilter.check();
await projectListPage.dinov2Check.check();
// the backend's default, until changed
await expect(projectListPage.dinov2Threshold).toHaveValue("0.15");
await projectListPage.dinov2Threshold.fill("0.2");

const [request] = await Promise.all([
waitForSave(page, "PUT"),
projectListPage.modal.confirmBtn.click(),
]);

expect(savedConfig(request)).toEqual({
...JSON.parse(PIXELMATCH_DEFAULT_CONFIG),
blurAmplitudeFilter: true,
dinov2Check: true,
dinov2Threshold: 0.2,
});
});

// with no service, the backend would send every run the filter passes to
// review
test("cannot be turned on where the server has no service", async ({
projectListPage,
page,
}) => {
await mockDinov2Status(page, { configured: false, available: false });

await projectListPage.editBtn.click();
await projectListPage.noiseFilter.check();

await expect(projectListPage.dinov2Check).toBeDisabled();
await expect(projectListPage.dinov2Status).toHaveText(
"Not available: this server has no DINOv2 service (DINOV2_URL).",
);
});

test("warns when the service is set up but not answering", async ({
projectListPage,
page,
}) => {
await mockDinov2Status(page, { configured: true, available: false });

await projectListPage.editBtn.click();
await projectListPage.noiseFilter.check();

await expect(projectListPage.dinov2Check).toBeEnabled();
await expect(projectListPage.dinov2Status).toContainText(
"The DINOv2 service is not answering right now",
);
});
});
37 changes: 37 additions & 0 deletions integration_tests/test/screens.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import {
mockGetUsers,
mockImage,
mockMatchingVariations,
mockDinov2Status,
mockOllamaModels,
mockTestRun,
mockUpdateTestRun,
Expand Down Expand Up @@ -250,6 +251,41 @@ const VIEWS: [name: string, body: Body][] = [
},
],

[
"edit project dialog with the DINOv2 check on",
async ({ projectListPage, shot }) => {
await projectListPage.editBtn.click();
await projectListPage.noiseFilter.check();
await projectListPage.dinov2CheckLabel.scrollIntoViewIfNeeded();
await projectListPage.dinov2Check.check();
// take the focus, and its tooltip, off the switch
await projectListPage.dialog.getByRole("heading").first().click();
await projectListPage.dinov2Threshold.scrollIntoViewIfNeeded();

await expect(projectListPage.dinov2Threshold).toHaveValue("0.15");
await expect(projectListPage.page.getByRole("tooltip")).toHaveCount(0);
await shot("edit-project-dinov2-check");
},
],

[
"edit project dialog on a server without DINOv2",
async ({ projectListPage, shot }) => {
await mockDinov2Status(projectListPage.page, {
configured: false,
available: false,
});
await projectListPage.editBtn.click();
await projectListPage.noiseFilter.check();
await projectListPage.dialog.getByRole("heading").first().click();
await projectListPage.dinov2Status.scrollIntoViewIfNeeded();

await expect(projectListPage.dinov2Check).toBeDisabled();
await expect(projectListPage.page.getByRole("tooltip")).toHaveCount(0);
await shot("edit-project-no-dinov2");
},
],

[
"delete project confirmation",
async ({ projectListPage, shot }) => {
Expand Down Expand Up @@ -556,6 +592,7 @@ for (const scheme of SCHEMES) {
]);
await mockGetUsers(page, [TEST_USER, EDITOR_USER, GUEST_USER]);
await mockOllamaModels(page, ["gemma3:12b", "llava:13b"]);
await mockDinov2Status(page, { configured: true, available: true });
await mockImages(page);
});

Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
10 changes: 10 additions & 0 deletions integration_tests/utils/mocks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -230,6 +230,16 @@ export const mockOllamaModels = (page: Page, names: string[]) => {
);
};

// whether the backend has a DINOv2 service set up, and whether it answers
export const mockDinov2Status = (
page: Page,
status: { configured: boolean; available: boolean },
) => {
return page.route(`${API_URL}/dinov2/status`, (route) =>
route.fulfill({ body: JSON.stringify(status) }),
);
};

// saving or applying ignore areas, and the comment, answer with nothing the
// dialog reads
export const mockUpdateIgnoreAreas = (page: Page) => {
Expand Down
30 changes: 30 additions & 0 deletions src/_helpers/useDinov2Status.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { useEffect, useState } from "react";
import { dinov2Service } from "../services";
import { Dinov2Status } from "../types";

// a backend that cannot answer has no DINOv2 check to offer: an older one
// without the endpoint, say
const NOT_CONFIGURED: Dinov2Status = { configured: false, available: false };

// Whether the backend can run the DINOv2 check; undefined while loading.
export function useDinov2Status(): Dinov2Status | undefined {
const [status, setStatus] = useState<Dinov2Status>();

useEffect(() => {
let active = true;
const update = (next: Dinov2Status) => {
if (active) {
setStatus(next);
}
};
dinov2Service
.getStatus()
.then(update)
.catch(() => update(NOT_CONFIGURED));
return () => {
active = false;
};
}, []);

return status;
}
71 changes: 38 additions & 33 deletions src/components/ProjectForm/BlurAmplitudeFields.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { TextValidator } from "react-material-ui-form-validator";
import { DEFAULT_BLUR_AMPLITUDE_THRESHOLD } from "../../constants";
import { BlurAmplitudeConfig } from "../../types/imageComparison";
import { Tooltip } from "../Tooltip";
import { Dinov2Fields } from "./Dinov2Fields";
import { useConfigHook } from "./useConfigHook";

// Rendered by the config form of every library that supports the filter.
Expand Down Expand Up @@ -35,39 +36,43 @@ export const BlurAmplitudeFields: React.FunctionComponent = () => {
/>
</Tooltip>
{config.blurAmplitudeFilter === true && (
<TextValidator
name="blurAmplitudeThreshold"
validators={["required", "minNumber:0", "maxNumber:128"]}
errorMessages={[
"Enter a threshold",
"Enter at least 0",
"Enter at most 128",
]}
slotProps={{
htmlInput: {
min: 0,
// the backend's ceiling: near 255 almost any change would pass
max: 128,
// the backend takes fractions; a whole step would make the
// browser refuse them
step: "any",
},
}}
margin="dense"
id="blurAmplitudeThreshold"
label="Rendering noise threshold"
helperText="Strongest blurred difference (0-128) still treated as noise. Lower is stricter."
type="number"
fullWidth
value={threshold}
onChange={(event: React.ChangeEvent<HTMLInputElement>) => {
const value = parseFloat(event.target.value);
updateConfig(
"blurAmplitudeThreshold",
Number.isNaN(value) ? null : value,
);
}}
/>
<React.Fragment>
<TextValidator
name="blurAmplitudeThreshold"
// minNumber and maxNumber compare whole numbers: 128.5 would pass
validators={["required", "minFloat:0", "maxFloat:128"]}
errorMessages={[
"Enter a threshold",
"Enter at least 0",
"Enter at most 128",
]}
slotProps={{
htmlInput: {
min: 0,
// the backend's ceiling: near 255 almost any change would pass
max: 128,
// the backend takes fractions; a whole step would make the
// browser refuse them
step: "any",
},
}}
margin="dense"
id="blurAmplitudeThreshold"
label="Rendering noise threshold"
helperText="Strongest blurred difference (0-128) still treated as noise. Lower is stricter."
type="number"
fullWidth
value={threshold}
onChange={(event: React.ChangeEvent<HTMLInputElement>) => {
const value = parseFloat(event.target.value);
updateConfig(
"blurAmplitudeThreshold",
Number.isNaN(value) ? null : value,
);
}}
/>
<Dinov2Fields />
</React.Fragment>
)}
</React.Fragment>
);
Expand Down
110 changes: 110 additions & 0 deletions src/components/ProjectForm/Dinov2Fields.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
import { FormControlLabel, FormHelperText, Switch } from "@mui/material";
import React from "react";
import { TextValidator } from "react-material-ui-form-validator";
import { useDinov2Status } from "../../_helpers/useDinov2Status";
import { DEFAULT_DINOV2_THRESHOLD } from "../../constants";
import { BlurAmplitudeConfig, Dinov2Status } from "../../types";
import { Tooltip } from "../Tooltip";
import { useConfigHook } from "./useConfigHook";

// What the check does on this server, said where it matters: with no service
// the backend sends every run the filter would pass to review.
const statusNote = (
status: Dinov2Status | undefined,
checked: boolean,
): { text: string; error?: boolean; warning?: boolean } | undefined => {
if (status === undefined) {
return undefined;
}
if (!status.configured) {
return checked
? {
text: "This server has no DINOv2 service: every run the filter would pass goes to review. Turn the check off or set DINOV2_URL on the backend.",
error: true,
}
: {
text: "Not available: this server has no DINOv2 service (DINOV2_URL).",
};
}
if (!status.available) {
return {
text: "The DINOv2 service is not answering right now. Until it does, runs the filter would pass go to review.",
warning: true,
};
}
return undefined;
};

// Rendered under the rendering-noise filter while it is on.
export const Dinov2Fields: React.FunctionComponent = () => {
const [config, updateConfig] = useConfigHook<BlurAmplitudeConfig>();
const status = useDinov2Status();
const checked = config.dinov2Check === true;
const note = statusNote(status, checked);

// absent: the backend default applies; null: the field was cleared, which
// the required validator then refuses
const threshold =
config.dinov2Threshold === undefined
? DEFAULT_DINOV2_THRESHOLD
: (config.dinov2Threshold ?? "");

return (
<React.Fragment>
<Tooltip title="Before a run passes as rendering noise, the DINOv2 service compares what the two screenshots show. Catches faint changes of content the blur misses, such as a missing light icon.">
<FormControlLabel
label="Check with DINOv2"
// a project saved with the check on can still turn it off
disabled={!checked && !status?.configured}
control={
<Switch
checked={checked}
onChange={(event, value) => updateConfig("dinov2Check", value)}
color="primary"
name="dinov2Check"
/>
}
/>
</Tooltip>
{note && (
<FormHelperText
id="dinov2Status"
error={note.error}
sx={note.warning ? { color: "warning.main" } : undefined}
>
{note.text}
</FormHelperText>
)}
{checked && (
<TextValidator
name="dinov2Threshold"
validators={["required", "minFloat:0.01", "maxFloat:2"]}
errorMessages={[
"Enter a threshold",
"Enter at least 0.01",
"Enter at most 2",
]}
slotProps={{
htmlInput: {
min: 0.01,
// cosine distance: 2 is as different as two patches get
max: 2,
step: "any",
},
}}
margin="dense"
id="dinov2Threshold"
label="DINOv2 threshold"
helperText="Content change (0.01-2) still treated as noise. Lower is stricter."
type="number"
fullWidth
value={threshold}
onChange={(event: React.ChangeEvent<HTMLInputElement>) => {
const value = parseFloat(event.target.value);

Check warning on line 103 in src/components/ProjectForm/Dinov2Fields.tsx

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Prefer `Number.parseFloat` over `parseFloat`.

See more on https://sonarcloud.io/project/issues?id=Visual-Regression-Tracker_frontend&issues=AaFuRItc4VpoBJCSONfi&open=AaFuRItc4VpoBJCSONfi&pullRequest=441
updateConfig("dinov2Threshold", Number.isNaN(value) ? null : value);
}}
/>
)}
</React.Fragment>
);
};
Loading
Loading