Merge visual review into full shell and preserve bilingual layout
This commit is contained in:
@@ -0,0 +1,337 @@
|
||||
import { expect, test, type Page } from "@playwright/test";
|
||||
import { canonicalWorkspaceFixture, workspaceRevisionFixture, workspaceSummaryFixture } from "../src/test/workspace-fixtures";
|
||||
|
||||
// These tests never forward API requests to an installation or invoke a real model.
|
||||
const workspace = "visual-fixture";
|
||||
const title = "Clinical research workspace";
|
||||
const model = { provider: "test", id: "review-model", name: "Review model", reasoning: true };
|
||||
const revision = workspaceRevisionFixture(workspace);
|
||||
const evidence = {
|
||||
id: "evidence:cohort", title: "Cohort definition", kind: "domain", language: "en",
|
||||
purposes: ["sql_generation"], applies_to: { concepts: ["cohort"], tables: [], columns: [] },
|
||||
payload: { rule: "# Approved cohort\n\nInclude eligible patients. Review the inclusion criteria before generating SQL." },
|
||||
provenance: { source_file: "research-guide.md", supporting_excerpts: ["Eligibility is evaluated at admission."] },
|
||||
review_items: [], file: "curated/domain/cohort.md", status: "active", revision: "1",
|
||||
};
|
||||
const evidencePage = {
|
||||
items: [evidence], total: 1, page: 1, page_size: 25, errors: [], initialized: true,
|
||||
active_revision: "1", pending_revision: null, source_reviews: [],
|
||||
location: { repository: "/installation/workspaces", workspace: "/installation/workspaces/clinical-research",
|
||||
runtime_workspace: "/data/workspaces/clinical-research", host: "installation", command: "tht workspace consolidate",
|
||||
git_commands: ["git status", "git diff"] },
|
||||
};
|
||||
|
||||
async function fixtures(page: Page, mode: "full" | "embedded" = "full") {
|
||||
const writes: string[] = [];
|
||||
await page.route("**/config.js", route => route.fulfill({
|
||||
contentType: "application/javascript",
|
||||
body: `window.__THOTHII_CONFIG__ = ${JSON.stringify({ backendBaseUrl: "/api", shell: { mode, defaultLocale: "en" } })};
|
||||
${mode === "embedded" ? `
|
||||
document.documentElement.setAttribute("data-bs-theme", "light");
|
||||
const language = document.createElement("select");
|
||||
language.className = "omics-language-select";
|
||||
language.dataset.lang = "en";
|
||||
language.hidden = true;
|
||||
document.body.prepend(language);
|
||||
` : ""}`,
|
||||
}));
|
||||
await page.route("http://127.0.0.1:5183/api/**", async route => {
|
||||
const path = new URL(route.request().url()).pathname.replace(/^\/api/, "");
|
||||
if (route.request().method() !== "GET") {
|
||||
writes.push(path);
|
||||
return route.fulfill({ status: 503, json: { message: "Visual fixture: no writes" } });
|
||||
}
|
||||
let body: unknown;
|
||||
if (path === "/auth/config") body = { mode: "mock", localLogin: false, oidcLogin: false };
|
||||
else if (path === "/me") body = { issuer: "test", subject: "admin", displayName: "Review administrator", isAdmin: true,
|
||||
roles: ["admin"], permissions: ["session.use", "session.read_all", "session.manage_all", "database.manage", "workspace.manage", "workspace.secrets.manage", "memory.manage", "evidence.manage", "pi.manage"], csrfToken: null, session: null };
|
||||
else if (path === "/settings") body = { workspace, provider: model.provider, model: model.id, thinking: "high" };
|
||||
else if (path === "/models") body = { models: [model] };
|
||||
else if (path === "/workspaces") body = [workspaceSummaryFixture(workspace, { displayName: title })];
|
||||
else if (path === `/workspaces/${workspace}`) body = { workspace: canonicalWorkspaceFixture(workspace), revision };
|
||||
else if (path.endsWith("/runtime-configuration")) body = { workspaceId: workspace, revision, configurationState: "ready", requirements: [] };
|
||||
else if (path.endsWith("/preprocessing")) body = { schemaVersion: 1, workspaceId: workspace, state: "ready", actionable: true, clearable: true,
|
||||
detail: "Catalog revision 1 is indexed.", metadataRevision: 1, preprocessedMetadataRevision: 1 };
|
||||
else if (path === "/workspace-registry/status") body = { branch: "main", ahead: 0, behind: 0, degraded: false };
|
||||
else if (path.endsWith("/memory/pending")) body = [];
|
||||
else if (path.endsWith("/memory")) body = { items: [], total: 0, page: 1, page_size: 25 };
|
||||
else if (path.endsWith("/evidence")) body = evidencePage;
|
||||
else if (path.includes("/evidence/")) body = { ...evidencePage, item: evidence };
|
||||
else if (path === "/pi-management/status") body = { ready: true, credentials: "present", hostPlatform: "macos", version: "0.80.3",
|
||||
config: { provider: model.provider, model: model.id, reasoning: "high" } };
|
||||
else if (path === "/sessions") body = [{ id: "archived-fixture", status: "finalized", question: "How many eligible patients?", name: "Cohort review",
|
||||
archived: true, active: false, workspace, created_at: "2026-09-01T12:00:00Z", updated_at: null, author: null, group: null, summary: null }];
|
||||
else if (path.endsWith("/documents")) body = [{ key: "question", title: "Original question", phase: "F1", format: "markdown", content: "# Cohort review\n\nHow many eligible patients were admitted in 2025?" }];
|
||||
else if (path === "/health/dwh") body = { ok: true, detail: "Available" };
|
||||
else if (path === "/catalog/databases") body = [];
|
||||
else if (path === "/catalog/metrics") body = { scope: "global", databaseId: null, tables: 0, columns: 0, sensitiveColumns: 0, relationships: 0,
|
||||
descriptionTargets: 0, describedTargets: 0, descriptionCoverage: 0, updatedAt: null };
|
||||
else if (path === "/catalog/metadata-generation/models") body = { models: [], default: null };
|
||||
else if (path.endsWith("-runs")) body = [];
|
||||
else return route.fulfill({ status: 404, json: { message: `Missing visual fixture: ${path}` } });
|
||||
return route.fulfill({ json: body });
|
||||
});
|
||||
return writes;
|
||||
}
|
||||
|
||||
async function navigation(page: Page) {
|
||||
const trigger = page.getByRole("button", { name: "Navigation", exact: true });
|
||||
if (await trigger.isVisible()) await trigger.click();
|
||||
}
|
||||
|
||||
async function admin(page: Page, name: string) {
|
||||
await navigation(page);
|
||||
const toggle = page.getByRole("button", { name: "Administration", exact: true });
|
||||
if (await toggle.getAttribute("aria-expanded") !== "true") await toggle.click();
|
||||
await page.getByRole("button", { name, exact: true }).click();
|
||||
}
|
||||
|
||||
async function inspect(page: Page) {
|
||||
return page.evaluate(() => {
|
||||
const visible = (e: Element) => {
|
||||
const box = e.getBoundingClientRect();
|
||||
return box.width > 0 && box.height > 0 && getComputedStyle(e).visibility !== "hidden" && !e.closest("[hidden]");
|
||||
};
|
||||
return {
|
||||
overflow: document.documentElement.scrollWidth - innerWidth,
|
||||
serif: [...document.querySelectorAll("h1,h2,h3,h4,button,label")].filter(visible)
|
||||
.filter(e => /Fraunces|Georgia|Times|ui-serif/.test(getComputedStyle(e).fontFamily)).map(e => e.textContent?.slice(0,60)),
|
||||
tiny: [...document.querySelectorAll("h1,h2,h3,h4,button,label,small")].filter(visible)
|
||||
.filter(e => parseFloat(getComputedStyle(e).fontSize) < 12).map(e => e.textContent?.slice(0,60)),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
for (const width of [390, 1280]) {
|
||||
test(`full shell keeps the visual revision across language and theme changes at ${width}px`, async ({ page }) => {
|
||||
await page.setViewportSize({ width, height: 1000 });
|
||||
const writes = await fixtures(page);
|
||||
await page.goto("/");
|
||||
const header = page.locator(".thot-full-header");
|
||||
await expect(header).toBeVisible();
|
||||
await page.evaluate(() => document.fonts.ready);
|
||||
expect(await page.evaluate(() => [...document.fonts].some(font =>
|
||||
font.family.includes("Manrope Variable") && font.status === "loaded",
|
||||
))).toBe(true);
|
||||
const layout = await page.locator(".thot-context-layout").boundingBox();
|
||||
expect(layout!.x).toBeGreaterThanOrEqual(20);
|
||||
expect(Math.abs(layout!.x - (width - layout!.x - layout!.width))).toBeLessThan(1);
|
||||
await admin(page, "Database");
|
||||
await expect(page.getByRole("heading", { name: "Database management", exact: true })).toBeVisible();
|
||||
await header.getByRole("combobox").selectOption("it");
|
||||
await expect(page.getByRole("heading", { name: "Gestione database", exact: true })).toBeVisible();
|
||||
await expect(page.locator(".thot-administration-eyebrow")).toHaveText("Amministrazione");
|
||||
await expect(page.locator(".thot-context-summary small")).toHaveText("Contesto di lavoro");
|
||||
await page.getByRole("button", { name: "Usa il tema scuro", exact: true }).click();
|
||||
await expect(page.locator(".thot-host")).toHaveAttribute("data-theme", "dark");
|
||||
expect(await inspect(page)).toEqual({ overflow: 0, serif: [], tiny: [] });
|
||||
await page.reload();
|
||||
await expect(page.getByRole("heading", { name: "Gestione database", exact: true })).toBeVisible();
|
||||
await expect(header.getByRole("combobox")).toHaveValue("it");
|
||||
await expect(page.locator(".thot-host")).toHaveAttribute("data-theme", "dark");
|
||||
await page.getByRole("button", { name: "Usa il tema chiaro", exact: true }).click();
|
||||
await header.getByRole("combobox").selectOption("en");
|
||||
await expect(page.getByRole("heading", { name: "Database management", exact: true })).toHaveCSS("font-size", "24px");
|
||||
expect(await inspect(page)).toEqual({ overflow: 0, serif: [], tiny: [] });
|
||||
expect(writes).toEqual([]);
|
||||
});
|
||||
}
|
||||
|
||||
for (const route of ["/", "/?thoth_route=administration%2Fdatabase"]) {
|
||||
test(`Core prompt remains readable after loading ${route}`, async ({ page }, testInfo) => {
|
||||
const writes = await fixtures(page);
|
||||
await page.goto(route);
|
||||
if (route !== "/") {
|
||||
await navigation(page);
|
||||
await page.getByRole("button", { name: "Return to session", exact: true }).click();
|
||||
}
|
||||
const prompt = page.getByRole("textbox", { name: "New question", exact: true });
|
||||
await expect(prompt).toBeVisible();
|
||||
await prompt.focus();
|
||||
const readable = async () => {
|
||||
await expect.poll(() => prompt.evaluate(el => {
|
||||
const style = getComputedStyle(el);
|
||||
return el.clientHeight >= parseFloat(style.lineHeight) + parseFloat(style.paddingTop) + parseFloat(style.paddingBottom);
|
||||
})).toBe(true);
|
||||
};
|
||||
await readable();
|
||||
await expect(prompt).toHaveCSS("outline-style", "none");
|
||||
await page.screenshot({ path: testInfo.outputPath("Prompt-focused.png") });
|
||||
// Full-mode gutters can wrap the empty placeholder on a narrow viewport.
|
||||
// Compare clearing with that same viewport's initial height, not a single-line constant.
|
||||
await page.setViewportSize({ width: 390, height: 900 });
|
||||
await readable();
|
||||
const emptyHeight = await prompt.evaluate(el => el.clientHeight);
|
||||
await prompt.fill("First line");
|
||||
await prompt.press("Shift+Enter");
|
||||
await prompt.press("a");
|
||||
await expect(prompt).toHaveValue("First line\na");
|
||||
await expect.poll(() => prompt.evaluate(el => el.clientHeight)).toBeGreaterThan(40);
|
||||
const question = "Compare eligible patients across the available cohorts. ".repeat(30);
|
||||
await prompt.fill(question);
|
||||
await page.setViewportSize({ width: 390, height: 900 });
|
||||
await readable();
|
||||
await expect.poll(() => prompt.evaluate(el => el.clientHeight)).toBe(160);
|
||||
expect(await prompt.evaluate(el => el.scrollHeight > el.clientHeight)).toBe(true);
|
||||
await admin(page, "Database");
|
||||
await navigation(page);
|
||||
await page.getByRole("button", { name: "Return to session", exact: true }).click();
|
||||
await expect(prompt).toHaveValue(question);
|
||||
await readable();
|
||||
await page.screenshot({ path: testInfo.outputPath("Prompt-multiline-mobile.png") });
|
||||
await prompt.fill("");
|
||||
await readable();
|
||||
await expect.poll(() => prompt.evaluate(el => el.clientHeight)).toBe(emptyHeight);
|
||||
expect(writes).toEqual([]);
|
||||
});
|
||||
}
|
||||
|
||||
for (const width of [390, 649, 768, 1280, 1600]) {
|
||||
test(`all surfaces retain shared typography at ${width}px`, async ({ page }, testInfo) => {
|
||||
await page.setViewportSize({ width, height: 1000 });
|
||||
const writes = await fixtures(page);
|
||||
await page.goto("/");
|
||||
await expect(page.getByTestId("app-shell")).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: /Working context/ })).toContainText(title);
|
||||
for (const name of ["Workspace", "Database", "Memory", "Evidence", "Pi configuration"]) {
|
||||
await admin(page, name);
|
||||
const heading = page.getByRole("heading", { name: `${name === "Pi configuration" ? "Pi" : name} management`, exact: true });
|
||||
await expect(heading).toBeVisible();
|
||||
await expect(heading).toHaveCSS("font-size", "24px");
|
||||
expect(await inspect(page)).toEqual({ overflow: 0, serif: [], tiny: [] });
|
||||
if (name === "Memory") await expect(page.getByText("No memory cards yet.", { exact: false })).toBeVisible();
|
||||
if (name === "Database") {
|
||||
const statuses = page.locator(".thot-fleet-ledger__header-status .thot-fleet-ledger__status-item");
|
||||
await expect(statuses).toHaveCount(3);
|
||||
const catalog = await statuses.nth(0).boundingBox();
|
||||
const operations = await statuses.nth(1).boundingBox();
|
||||
const metadata = await statuses.nth(2).boundingBox();
|
||||
const summary = await page.locator(".thot-fleet-ledger__kpi-section > h2").boundingBox();
|
||||
const summaryBlock = await page.locator(".thot-fleet-ledger__kpi-section").boundingBox();
|
||||
const navigationBlock = await page.locator(".thot-fleet-ledger__navigation").boundingBox();
|
||||
const gridBlock = await page.locator(".thot-fleet-ledger__content").boundingBox();
|
||||
const databasesLabel = await page.locator(".thot-fleet-ledger__navigation").getByText("Databases", { exact: true }).boundingBox();
|
||||
const tablesLabel = await page.locator(".thot-fleet-ledger__kpi-label").getByText("Tables", { exact: true }).boundingBox();
|
||||
expect(Math.abs(summaryBlock!.x - gridBlock!.x)).toBeLessThan(1);
|
||||
expect(Math.abs(navigationBlock!.x - gridBlock!.x)).toBeLessThan(1);
|
||||
expect(Math.abs(summary!.x - databasesLabel!.x)).toBeLessThan(1);
|
||||
expect(Math.abs(summary!.x - tablesLabel!.x)).toBeLessThan(1);
|
||||
const gridLabel = await page.locator(".thot-fleet-grid-toolbar").getByText("Workspace databases", { exact: true }).boundingBox();
|
||||
expect(Math.abs(summary!.x - gridLabel!.x)).toBeLessThan(1);
|
||||
expect(Math.abs(summaryBlock!.width - gridBlock!.width)).toBeLessThan(1);
|
||||
expect(Math.abs(navigationBlock!.width - gridBlock!.width)).toBeLessThan(1);
|
||||
const contextTrigger = page.getByRole("button", { name: /Working context/ });
|
||||
await contextTrigger.click();
|
||||
const contextBody = page.locator(".thot-context-body");
|
||||
await expect(contextBody).toBeVisible();
|
||||
await expect(contextBody).not.toContainText("Your last choices take precedence");
|
||||
await expect(contextBody).not.toContainText("Installation default");
|
||||
await expect(contextBody).not.toContainText("Last choice");
|
||||
const contextBlock = await contextBody.boundingBox();
|
||||
const workspaceSelect = await contextBody.getByRole("combobox", { name: "Workspace", exact: true }).boundingBox();
|
||||
expect(Math.abs(contextBlock!.x - summaryBlock!.x)).toBeLessThan(1);
|
||||
expect(Math.abs(workspaceSelect!.x - summary!.x)).toBeLessThan(1);
|
||||
expect(await inspect(page)).toEqual({ overflow: 0, serif: [], tiny: [] });
|
||||
await page.screenshot({ path: testInfo.outputPath("Context-expanded.png") });
|
||||
await contextBody.getByRole("button", { name: "Done", exact: true }).click();
|
||||
expect(Math.abs(catalog!.x - summary!.x)).toBeLessThan(1);
|
||||
const workArea = await page.locator(".thot-fleet-ledger__workspace").boundingBox();
|
||||
if (workArea!.width >= 768) {
|
||||
const coverage = await page.locator(".thot-fleet-ledger__kpi-label").filter({ hasText: "Description coverage" }).boundingBox();
|
||||
expect(Math.abs(metadata!.x - coverage!.x)).toBeLessThan(1);
|
||||
expect(Math.abs(operations!.x - (catalog!.x + metadata!.x) / 2)).toBeLessThan(1);
|
||||
}
|
||||
for (const status of await statuses.all()) {
|
||||
const label = await status.locator(".thot-fleet-ledger__status-label > span:first-child").boundingBox();
|
||||
const mark = await status.locator(".thot-fleet-ledger__status-mark").boundingBox();
|
||||
const value = await status.locator(".thot-fleet-ledger__status-value").boundingBox();
|
||||
expect(mark!.x - (label!.x + label!.width)).toBeGreaterThanOrEqual(7);
|
||||
expect(Math.abs(label!.x - value!.x)).toBeLessThan(1);
|
||||
}
|
||||
}
|
||||
if (name === "Evidence") {
|
||||
const search = await page.getByRole("textbox", { name: "Search Evidence" }).boundingBox();
|
||||
expect(search!.width).toBeGreaterThan(180);
|
||||
}
|
||||
await page.screenshot({ path: testInfo.outputPath(`${name.replaceAll(" ", "-")}.png`) });
|
||||
}
|
||||
await page.getByRole("button", { name: "Host maintenance", exact: true }).click();
|
||||
await expect(page.getByRole("tab", { name: "macOS", exact: true })).toHaveAttribute("aria-selected", "true");
|
||||
await expect(page.getByRole("region", { name: "Pi operator workflow" })).toHaveCSS("font-size", "16px");
|
||||
await page.screenshot({ path: testInfo.outputPath("Pi-guide.png") });
|
||||
await navigation(page);
|
||||
await page.getByRole("tab", { name: "All sessions", exact: true }).click();
|
||||
await expect(page.locator(".thot-wordmark--sidebar")).toHaveCSS("font-size", "32px");
|
||||
await page.getByRole("button", { name: /Archive \(1\)/ }).click();
|
||||
await page.getByRole("button", { name: "Cohort review", exact: true }).click();
|
||||
const documents = page.getByRole("complementary", { name: "Session summary" });
|
||||
await expect(documents).toBeVisible();
|
||||
await expect(documents.getByRole("heading", { name: "Original question" })).toBeVisible();
|
||||
const bounds = await documents.boundingBox();
|
||||
expect(bounds!.width).toBeGreaterThanOrEqual(Math.min(width, 300));
|
||||
expect(await inspect(page)).toEqual({ overflow: 0, serif: [], tiny: [] });
|
||||
await page.screenshot({ path: testInfo.outputPath("Sessions.png") });
|
||||
await page.getByRole("button", { name: "Close panel", exact: true }).click();
|
||||
await expect(page.getByRole("textbox", { name: "New question", exact: true })).toBeVisible();
|
||||
await expect(page.locator(".thot-wordmark--core")).toHaveCSS("font-size", "48px");
|
||||
await page.screenshot({ path: testInfo.outputPath("Core.png") });
|
||||
expect(writes).toEqual([]);
|
||||
});
|
||||
}
|
||||
|
||||
test("theme follows the app, not an unrelated OS preference; keyboard tabs and dirty guard remain", async ({ page }, testInfo) => {
|
||||
await page.setViewportSize({ width: 1280, height: 900 });
|
||||
const writes = await fixtures(page);
|
||||
await page.emulateMedia({ colorScheme: "dark" });
|
||||
await page.goto("/");
|
||||
await expect(page.getByTestId("app-shell")).toBeVisible();
|
||||
await expect(page.getByRole("banner", { name: "Application header" })).toBeVisible();
|
||||
await admin(page, "Pi configuration");
|
||||
const testButton = page.getByRole("button", { name: "Test catalog default", exact: true });
|
||||
await expect(testButton).toHaveCSS("background-color", "oklch(0.9985 0.0006 17.2)");
|
||||
await page.getByRole("button", { name: "Use dark theme", exact: true }).click();
|
||||
await expect(page.locator(".thot-context-trigger")).toHaveCSS("color", "oklch(0.931 0 90)");
|
||||
await expect(testButton).toHaveCSS("background-color", "oklch(0.62 0.008 23.2 / 0.3)");
|
||||
await page.screenshot({ path: testInfo.outputPath("Pi-dark.png") });
|
||||
const guideButton = page.getByRole("button", { name: "Host maintenance", exact: true });
|
||||
await guideButton.click();
|
||||
const mac = page.getByRole("tab", { name: "macOS", exact: true });
|
||||
await mac.focus(); await page.keyboard.press("ArrowRight");
|
||||
await expect(page.getByRole("tab", { name: "Windows", exact: true })).toBeFocused();
|
||||
await expect(page.getByRole("tab", { name: "Windows", exact: true })).toHaveAttribute("aria-selected", "true");
|
||||
await admin(page, "Memory");
|
||||
await page.getByRole("button", { name: "New card", exact: true }).click();
|
||||
await page.getByRole("textbox", { name: "Title", exact: true }).fill("Keep this draft");
|
||||
await page.getByRole("button", { name: "Return to session", exact: true }).click();
|
||||
await expect(page.getByRole("textbox", { name: "Title", exact: true })).toHaveValue("Keep this draft");
|
||||
await page.screenshot({ path: testInfo.outputPath("Memory-dark-unsaved.png") });
|
||||
await page.getByRole("button", { name: "Cancel", exact: true }).click();
|
||||
await page.getByRole("button", { name: "Return to session", exact: true }).click();
|
||||
await expect(page.getByRole("textbox", { name: "New question", exact: true })).toBeVisible();
|
||||
expect(writes).toEqual([]);
|
||||
});
|
||||
|
||||
test("host header and left rail leave the application a bounded working area", async ({ page }, testInfo) => {
|
||||
await page.setViewportSize({ width: 1280, height: 1000 });
|
||||
await fixtures(page, "embedded");
|
||||
await page.goto("/");
|
||||
await expect(page.getByTestId("app-shell")).toBeVisible();
|
||||
await expect(page.locator(".thot-full-header")).toHaveCount(0);
|
||||
// Geometry fixture, not a replacement for acceptance inside the real Omics deployment.
|
||||
await page.addStyleTag({ content: `
|
||||
body { margin: 0; }
|
||||
#root { margin-left: 240px; margin-top: 72px; --thoth-app-height: calc(100dvh - 72px); }
|
||||
.px-4 { padding-left: 2.25rem !important; padding-right: 2.25rem !important; }
|
||||
` });
|
||||
await admin(page, "Workspace");
|
||||
const shell = await page.getByTestId("app-shell").boundingBox();
|
||||
expect(shell!.x).toBe(240);
|
||||
expect(shell!.x + shell!.width).toBeLessThanOrEqual(1280);
|
||||
expect(shell!.y + shell!.height).toBeLessThanOrEqual(1000);
|
||||
expect(await inspect(page)).toEqual({ overflow: 0, serif: [], tiny: [] });
|
||||
await page.screenshot({ path: testInfo.outputPath("Host-bounded-Workspace.png") });
|
||||
await admin(page, "Evidence");
|
||||
const search = await page.getByRole("textbox", { name: "Search Evidence" }).boundingBox();
|
||||
expect(search!.width).toBeGreaterThan(180);
|
||||
expect(await inspect(page)).toEqual({ overflow: 0, serif: [], tiny: [] });
|
||||
});
|
||||
Reference in New Issue
Block a user