/**
* Tests for the ProjectsPage component.
*/
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { render } from '../utils';
import { ProjectsPage, ProjectModal } from '../../pages/ProjectsPage';
import { eligibleParents } from '../../utils/projectTree';
import { http, HttpResponse } from 'msw';
import { server } from '../mocks/server';
const mockProjects = [
{
id: 1,
name: 'Functional Parts',
description: 'Useful household items',
color: '#00ae42',
archive_count: 10,
total_print_time_seconds: 36000,
total_filament_grams: 500,
created_at: '2024-01-01T00:00:00Z',
updated_at: '2024-01-15T00:00:00Z',
},
{
id: 2,
name: 'Art Collection',
description: 'Decorative prints',
color: '#ff5500',
archive_count: 5,
total_print_time_seconds: 18000,
total_filament_grams: 200,
created_at: '2024-01-05T00:00:00Z',
updated_at: '2024-01-10T00:00:00Z',
},
];
describe('ProjectsPage', () => {
beforeEach(() => {
server.use(
http.get('/api/v1/projects/', () => {
return HttpResponse.json(mockProjects);
}),
http.post('/api/v1/projects/', async ({ request }) => {
const body = await request.json() as { name: string };
return HttpResponse.json({ id: 3, name: body.name, color: '#00ae42', archive_count: 0 });
}),
http.delete('/api/v1/projects/:id', () => {
return HttpResponse.json({ success: true });
})
);
});
describe('rendering', () => {
it('renders the page title', async () => {
render();
await waitFor(() => {
expect(screen.getByText('Projects')).toBeInTheDocument();
});
});
it('shows project cards', async () => {
render();
await waitFor(() => {
expect(screen.getByText('Functional Parts')).toBeInTheDocument();
expect(screen.getByText('Art Collection')).toBeInTheDocument();
});
});
it('shows project descriptions', async () => {
render();
await waitFor(() => {
expect(screen.getByText('Useful household items')).toBeInTheDocument();
expect(screen.getByText('Decorative prints')).toBeInTheDocument();
});
});
});
describe('project info', () => {
it('shows archive count', async () => {
render();
await waitFor(() => {
// Project cards should show archive counts
expect(screen.getByText('Functional Parts')).toBeInTheDocument();
});
});
it('shows project colors', async () => {
render();
await waitFor(() => {
const functionalParts = screen.getByText('Functional Parts');
expect(functionalParts).toBeInTheDocument();
// Color is applied as style
});
});
});
describe('create project', () => {
it('has new project button', async () => {
render();
await waitFor(() => {
expect(screen.getByText('New Project')).toBeInTheDocument();
});
});
it('opens create modal on click', async () => {
const user = userEvent.setup();
render();
await waitFor(() => {
expect(screen.getByText('New Project')).toBeInTheDocument();
});
await user.click(screen.getByText('New Project'));
// Modal should open - look for modal content
await waitFor(() => {
// Modal may show "Create Project" or similar text
const modalContent = screen.queryByText(/create/i) ||
screen.queryByRole('dialog') ||
screen.queryByText(/name/i);
expect(modalContent).toBeTruthy();
});
});
});
describe('empty state', () => {
it('shows empty state when no projects', async () => {
server.use(
http.get('/api/v1/projects/', () => {
return HttpResponse.json([]);
})
);
render();
await waitFor(() => {
// Either empty state message or the page title should be visible
const emptyMsg = screen.queryByText(/no projects/i);
const pageTitle = screen.queryByText('Projects');
expect(emptyMsg || pageTitle).toBeTruthy();
});
});
});
// #1155 — URL link icon + cover image thumbnail on project cards.
describe('URL link and cover image (#1155)', () => {
it('renders an external-link icon next to the project name when URL is set', async () => {
server.use(
http.get('/api/v1/projects/', () =>
HttpResponse.json([
{
...mockProjects[0],
url: 'https://makerworld.com/models/12345',
cover_image_filename: null,
},
])
)
);
render();
const link = await screen.findByLabelText(/Open project URL/i);
expect(link).toBeInTheDocument();
expect(link.getAttribute('href')).toBe('https://makerworld.com/models/12345');
expect(link.getAttribute('target')).toBe('_blank');
expect(link.getAttribute('rel')).toContain('noopener');
});
it('does not render the link icon when URL is not set', async () => {
// Default fixture has no `url` field — verify the icon is absent.
render();
await waitFor(() => {
expect(screen.getByText('Functional Parts')).toBeInTheDocument();
});
expect(screen.queryByLabelText(/Open project URL/i)).not.toBeInTheDocument();
});
it('clicking the URL link does not bubble to the card onClick', async () => {
server.use(
http.get('/api/v1/projects/', () =>
HttpResponse.json([
{
...mockProjects[0],
url: 'https://example.com',
cover_image_filename: null,
},
])
)
);
const user = userEvent.setup();
render();
const link = await screen.findByLabelText(/Open project URL/i);
// Prevent the underlying anchor from triggering jsdom navigation noise
// — we only need the propagation guard verified.
link.addEventListener('click', (e) => e.preventDefault(), { once: true });
await user.click(link);
// No navigate / detail-page transition should have happened. Card root
// is still rendered.
expect(screen.getByText('Functional Parts')).toBeInTheDocument();
});
it('renders a cover image thumbnail when cover_image_filename is set', async () => {
server.use(
http.get('/api/v1/projects/', () =>
HttpResponse.json([
{
...mockProjects[0],
url: null,
cover_image_filename: 'cover_abc.png',
},
])
)
);
render();
const img = await screen.findByAltText(/Project cover photo/i);
expect(img).toBeInTheDocument();
// Card thumbnail uses the GET endpoint URL, project.id is 1.
expect(img.getAttribute('src')).toContain('/projects/1/cover-image');
});
it('renders the portal-mounted hover preview only while the thumbnail is hovered (#1155)', async () => {
// The portal escapes the project card's ``overflow-hidden``, which
// would otherwise clip a 384×384 popover anchored to a 40×40
// thumbnail. Pin the contract end-to-end:
// - no preview in the DOM by default (mouseenter not fired yet)
// - mouseenter mounts a popover at document.body level (not
// nested in the card subtree, which would re-introduce the
// clipping bug)
// - mouseleave unmounts it
// - the popover ``
`` points at the same cover-image URL as
// the small thumbnail (object-contain so portrait/landscape
// MakerWorld photos aren't cropped)
const { fireEvent } = await import('@testing-library/react');
server.use(
http.get('/api/v1/projects/', () =>
HttpResponse.json([
{
...mockProjects[0],
url: null,
cover_image_filename: 'cover_abc.png',
},
])
)
);
render();
const thumb = await screen.findByAltText(/Project cover photo/i);
// Default: no popover yet.
expect(document.querySelectorAll('[aria-hidden="true"] img').length).toBe(0);
// Hover: walk up to the wrapper div the component attaches its
// mouseenter to. The portal mounts under document.body, NOT
// nested in the card subtree.
const wrapper = thumb.closest('[class*="flex-shrink-0"]') as HTMLElement;
expect(wrapper).not.toBeNull();
fireEvent.mouseEnter(wrapper);
const previewImg = document.querySelector('[aria-hidden="true"] img') as HTMLImageElement | null;
expect(previewImg).not.toBeNull();
expect(previewImg!.getAttribute('src')).toContain('/projects/1/cover-image');
expect(previewImg!.className).toContain('object-contain');
// The portal mounts on document.body (or one of its direct
// descendants), not inside the card — that's the whole point of
// the portal, so a future refactor that drops the portal would
// re-introduce the clipping regression.
const popover = previewImg!.closest('[aria-hidden="true"]') as HTMLElement;
expect(popover.closest('[class*="rounded-xl"]')).toBeNull();
// Unmount on leave.
fireEvent.mouseLeave(wrapper);
expect(document.querySelectorAll('[aria-hidden="true"] img').length).toBe(0);
});
it('does not render a hover preview when there is no cover image', async () => {
server.use(
http.get('/api/v1/projects/', () =>
HttpResponse.json([
{ ...mockProjects[0], url: null, cover_image_filename: null },
])
)
);
render();
await screen.findByText(mockProjects[0].name);
expect(screen.queryByAltText(/Project cover photo/i)).toBeNull();
// No aria-hidden img should ever appear because no thumbnail to
// hover means the portal-mounting component never renders.
expect(document.querySelectorAll('[aria-hidden="true"] img').length).toBe(0);
});
});
describe('modal scrolls on short viewports (#1642)', () => {
/**
* Reporter on a Pi screen couldn't reach the Save button when editing a
* project because the modal had no max-h / overflow. The structural fix
* puts a max-h on the card, the form fields in a `flex-1 overflow-y-auto`
* wrapper, and the Save/Cancel buttons in a `flex-shrink-0` sibling so
* they're always visible regardless of scroll position.
*
* jsdom doesn't compute layout heights so we can't simulate the actual
* overflow. We pin the structure instead: the scrollable wrapper exists,
* the Save button is NOT a descendant of it, and the card has a max-h.
* A future refactor that removes any of these would re-introduce the bug.
*/
const editableProject = {
id: 7,
name: 'Spool holder',
description: null,
color: '#00ae42',
url: null,
cover_image_filename: null,
archive_count: 0,
total_print_time_seconds: 0,
total_filament_grams: 0,
target_plates_count: null,
target_parts_count: null,
tags: null,
due_date: null,
priority: null,
budget: null,
status: 'active' as const,
created_at: '2024-01-01T00:00:00Z',
updated_at: '2024-01-01T00:00:00Z',
};
it('renders the action footer outside the scrollable fields wrapper', () => {
render(
{}}
onSave={() => {}}
isLoading={false}
currencySymbol="€"
t={((k: string) => k) as never}
/>,
);
const saveButton = screen.getByRole('button', { name: 'common.save' });
const scrollable = document.querySelector('.overflow-y-auto');
expect(scrollable).not.toBeNull();
// The save button must live OUTSIDE the scrollable region — otherwise
// a long form pushes it below the fold on short viewports (#1642).
expect(scrollable!.contains(saveButton)).toBe(false);
});
it('caps the modal card height so it cannot exceed the viewport', () => {
render(
{}}
onSave={() => {}}
isLoading={false}
currencySymbol="€"
t={((k: string) => k) as never}
/>,
);
// Card has max-h set so it never extends past the viewport — without
// this, vertical-center alignment pushes the bottom of the modal
// (including the action footer) off-screen.
const card = document.querySelector('.max-h-\\[calc\\(100vh-2rem\\)\\]');
expect(card).not.toBeNull();
});
});
describe('edit dialog seeds itself from the list payload (#2536)', () => {
/**
* The same ProjectModal is opened from the projects list and from the
* project detail page. The detail page hands it a full project; the list
* hands it a list item. Reporter saw an empty tags field when editing from
* the list, because the list payload didn't carry tags — and, unreported,
* the dialog then submitted its default priority over the stored one.
*/
const listItem = {
id: 7,
name: 'Spool holder',
description: null,
color: '#00ae42',
status: 'active',
target_count: null,
target_parts_count: null,
budget: null,
tags: 'prototype,client-work',
due_date: '2026-08-01T12:00:00Z',
priority: 'high',
created_at: '2024-01-01T00:00:00Z',
archive_count: 0,
total_items: 0,
completed_count: 0,
failed_count: 0,
queue_count: 0,
progress_percent: null,
archives: [],
url: null,
cover_image_filename: null,
};
const renderModal = (onSave: (data: unknown) => void) =>
render(
{}}
onSave={onSave as never}
isLoading={false}
currencySymbol="€"
t={((k: string) => k) as never}
/>,
);
const tagsInput = () => screen.getByPlaceholderText('projects.tagsPlaceholder') as HTMLInputElement;
const dueDateInput = () => document.querySelector('input[type="date"]') as HTMLInputElement;
const prioritySelect = () =>
Array.from(document.querySelectorAll('select')).find((s) =>
s.querySelector('option[value="urgent"]'),
) as HTMLSelectElement;
it('prefills tags, due date and priority from a list item', () => {
renderModal(() => {});
expect(tagsInput().value).toBe('prototype,client-work');
expect(dueDateInput().value).toBe('2026-08-01');
expect(prioritySelect().value).toBe('high');
});
it('does not downgrade a stored priority when saving an untouched field', async () => {
const user = userEvent.setup();
const onSave = vi.fn();
renderModal(onSave);
await user.click(screen.getByRole('button', { name: 'common.save' }));
// Before the fix the dialog fell back to its 'normal' default and sent
// that, silently demoting a high/urgent project edited from the list.
expect(onSave).toHaveBeenCalledWith(
expect.objectContaining({ priority: 'high', tags: 'prototype,client-work' }),
);
});
it('sends null when an existing tag list is cleared', async () => {
const user = userEvent.setup();
const onSave = vi.fn();
renderModal(onSave);
await user.clear(tagsInput());
await user.click(screen.getByRole('button', { name: 'common.save' }));
// undefined would drop the key from the PATCH body and the backend would
// keep the old tags — the field has to be cleared explicitly.
expect(onSave).toHaveBeenCalledWith(expect.objectContaining({ tags: null }));
});
});
describe('status tab badges (#2888)', () => {
// Every tab is counted from the whole fleet, not from the page below it.
const fleet = [
{ ...mockProjects[0], id: 1, status: 'active' },
{ ...mockProjects[1], id: 2, status: 'completed' },
{ ...mockProjects[1], id: 3, name: 'Last Year', status: 'archived' },
{ ...mockProjects[1], id: 4, name: 'Year Before', status: 'archived' },
];
beforeEach(() => {
// Stands in for the API's own status filter, so `projects` really does
// hold one status at a time the way it does in the app.
server.use(
http.get('/api/v1/projects/', ({ request }) => {
const status = new URL(request.url).searchParams.get('status');
return HttpResponse.json(status ? fleet.filter((p) => p.status === status) : fleet);
}),
);
});
const badgeFor = (label: string) =>
screen.getByRole('button', { name: new RegExp(`^${label}`) }).querySelector('span:last-child');
it('counts the tabs the filter is hiding', async () => {
// The page opens on Active, so before this every other tab counted zero
// and dropped its badge -- the reporter's fleet of thirty finished
// projects showed Completed bare next to "Active 5".
render();
await waitFor(() => expect(badgeFor('Active')).toHaveTextContent('1'));
expect(badgeFor('Completed')).toHaveTextContent('1');
expect(badgeFor('Archived')).toHaveTextContent('2');
expect(badgeFor('All')).toHaveTextContent('4');
});
it('keeps the same counts after switching tabs', async () => {
const user = userEvent.setup();
render();
await waitFor(() => expect(badgeFor('Archived')).toHaveTextContent('2'));
await user.click(screen.getByRole('button', { name: /^Archived/ }));
// Only the page below changes; the badges describe the fleet either way.
await waitFor(() => expect(screen.getByText('Last Year')).toBeInTheDocument());
expect(badgeFor('Active')).toHaveTextContent('1');
expect(badgeFor('Completed')).toHaveTextContent('1');
expect(badgeFor('All')).toHaveTextContent('4');
});
});
describe('nesting projects under a master project (#1264)', () => {
const listItem = (over: Record) => ({
id: 1,
name: 'Airframe',
description: null,
color: '#00ae42',
status: 'active',
target_count: null,
target_parts_count: null,
target_sets: null,
budget: null,
tags: null,
due_date: null,
priority: 'normal',
created_at: '2024-01-01T00:00:00Z',
archive_count: 0,
total_items: 0,
completed_count: 0,
failed_count: 0,
queue_count: 0,
progress_percent: null,
parent_id: null,
child_count: 0,
archives: [],
url: null,
cover_image_filename: null,
...over,
});
describe('eligibleParents', () => {
it('offers every other project when creating a new one', () => {
const projects = [listItem({ id: 1 }), listItem({ id: 2, name: 'Wing' })];
expect(eligibleParents(projects, undefined).map((p) => p.id)).toEqual([1, 2]);
});
it('never offers the project itself', () => {
const projects = [listItem({ id: 1 }), listItem({ id: 2, name: 'Wing' })];
expect(eligibleParents(projects, 1).map((p) => p.id)).toEqual([2]);
});
it('never offers a project already nested underneath', () => {
// The API rejects a cycle, so offering one would only produce an error
// the user can do nothing about.
const projects = [
listItem({ id: 1 }),
listItem({ id: 2, name: 'Wing', parent_id: 1 }),
listItem({ id: 3, name: 'Unrelated' }),
];
expect(eligibleParents(projects, 1).map((p) => p.id)).toEqual([3]);
});
it('excludes a grandchild listed before its own parent', () => {
// Reversion-proof for a single-pass version: the list arrives in update
// order, so a grandchild can precede the parent that blocks it.
const projects = [
listItem({ id: 3, name: 'Spar', parent_id: 2 }),
listItem({ id: 2, name: 'Wing', parent_id: 1 }),
listItem({ id: 1 }),
];
expect(eligibleParents(projects, 1)).toEqual([]);
});
});
it('sends 0 to clear a parent, because null would read as omitted', async () => {
const user = userEvent.setup();
const onSave = vi.fn();
server.use(http.get('/api/v1/projects/', () => HttpResponse.json([listItem({ id: 9, name: 'Other' })])));
render(
{}}
onSave={onSave as never}
isLoading={false}
currencySymbol="EUR"
t={((k: string) => k) as never}
/>,
);
const parentSelect = () =>
Array.from(document.querySelectorAll('select')).find((s) =>
s.querySelector('option[value=""]'),
) as HTMLSelectElement;
await waitFor(() => expect(parentSelect().value).toBe('9'));
await user.selectOptions(parentSelect(), '');
await user.click(screen.getByRole('button', { name: 'common.save' }));
expect(onSave).toHaveBeenCalledWith(expect.objectContaining({ parent_id: 0 }));
});
it('draws a sub-project inside its parent group, not loose in the grid', async () => {
server.use(
http.get('/api/v1/projects/', () =>
HttpResponse.json([
listItem({ id: 1, name: 'Airframe', child_count: 1 }),
listItem({ id: 2, name: 'Wing', parent_id: 1 }),
]),
),
);
render();
await waitFor(() => {
expect(screen.getByText('Sub-projects of Airframe')).toBeInTheDocument();
});
// Two cards sitting columns apart cannot show that they belong together,
// however they are captioned — the child has to be physically inside the
// parent's group.
const group = screen.getByText('Sub-projects of Airframe').closest('.col-span-full');
expect(group).not.toBeNull();
expect(group!.textContent).toContain('Wing');
// And the caption is then redundant.
expect(screen.queryByText('Part of Airframe')).not.toBeInTheDocument();
});
it('keeps a sub-project visible when the filter hides its parent', async () => {
server.use(
http.get('/api/v1/projects/', ({ request }) => {
const status = new URL(request.url).searchParams.get('status');
const all = [
listItem({ id: 1, name: 'Airframe', status: 'completed', child_count: 1 }),
listItem({ id: 2, name: 'Wing', parent_id: 1 }),
];
return HttpResponse.json(status ? all.filter((p) => p.status === status) : all);
}),
);
// The page opens on the Active filter, which already hides the completed
// parent and leaves its active child with nowhere to nest.
render();
// Nothing to nest under, so it stays a top-level card — and then the
// caption is the only thing that can say where it belongs.
await waitFor(() => {
expect(screen.getByText('Part of Airframe')).toBeInTheDocument();
});
expect(screen.queryByText('Sub-projects of Airframe')).not.toBeInTheDocument();
});
it('still shows every project when the data holds a parent cycle', async () => {
// Grouping hides anything that has a visible parent, and a cycle has no
// root — so A -> B -> A would take both projects off the page entirely.
// A database written before the API refused a cycle can still hold one.
server.use(
http.get('/api/v1/projects/', () =>
HttpResponse.json([
listItem({ id: 1, name: 'Alpha', parent_id: 2 }),
listItem({ id: 2, name: 'Beta', parent_id: 1 }),
]),
),
);
render();
await waitFor(() => {
expect(screen.getByText('Alpha')).toBeInTheDocument();
});
// Exactly once each: following the cycle round would redraw them.
expect(screen.getAllByText('Alpha')).toHaveLength(1);
expect(screen.getAllByText('Beta')).toHaveLength(1);
});
it('nests a grandchild under its own parent, not under the top of the tree', async () => {
server.use(
http.get('/api/v1/projects/', () =>
HttpResponse.json([
listItem({ id: 1, name: 'Airframe', child_count: 1 }),
listItem({ id: 2, name: 'Wing', parent_id: 1, child_count: 1 }),
listItem({ id: 3, name: 'Spar', parent_id: 2 }),
]),
),
);
render();
await waitFor(() => {
expect(screen.getByText('Sub-projects of Wing')).toBeInTheDocument();
});
const wingGroup = screen.getByText('Sub-projects of Wing').closest('.col-span-full');
expect(wingGroup!.textContent).toContain('Spar');
// Flattening every descendant onto the root would lose the depth.
expect(wingGroup!.textContent).not.toContain('Airframe');
});
describe('folding sub-project groups away (#2991)', () => {
// localStorage is globally mocked in setup.ts, so each test programs the
// preference it wants explicitly.
const getItemMock = localStorage.getItem as ReturnType;
const setItemMock = localStorage.setItem as ReturnType;
beforeEach(() => {
getItemMock.mockReset();
setItemMock.mockReset();
});
// The mock is module-global: an implementation left behind here would
// silently collapse the groups of every later test.
afterEach(() => {
getItemMock.mockReset();
setItemMock.mockReset();
});
const serveAirframe = () =>
server.use(
http.get('/api/v1/projects/', () =>
HttpResponse.json([
listItem({ id: 1, name: 'Airframe', child_count: 1 }),
listItem({ id: 2, name: 'Wing', parent_id: 1 }),
]),
),
);
it('leaves groups open when no preference has been stored', async () => {
getItemMock.mockReturnValue(null);
serveAirframe();
render();
await waitFor(() => {
expect(screen.getByText('Sub-projects of Airframe')).toBeInTheDocument();
});
// The page behaved this way before the toggle existed, and an upgrade
// that hides half of somebody's projects is not a fix.
expect(screen.getByText('Wing')).toBeInTheDocument();
});
it('opens with the groups shut when that is what was stored', async () => {
getItemMock.mockImplementation((key: string) =>
key === 'projects-collapse-subprojects' ? 'true' : null,
);
serveAirframe();
render();
await waitFor(() => {
expect(screen.getByText('Sub-projects of Airframe')).toBeInTheDocument();
});
// The caption stays — it is the way back in, and it says what is behind
// it. Only the cards go.
expect(screen.queryByText('Wing')).not.toBeInTheDocument();
});
it('shuts one group from its own caption, leaving the others alone', async () => {
getItemMock.mockReturnValue(null);
server.use(
http.get('/api/v1/projects/', () =>
HttpResponse.json([
listItem({ id: 1, name: 'Airframe', child_count: 1 }),
listItem({ id: 2, name: 'Wing', parent_id: 1 }),
listItem({ id: 3, name: 'Fuselage', child_count: 1 }),
listItem({ id: 4, name: 'Bulkhead', parent_id: 3 }),
]),
),
);
const user = userEvent.setup();
render();
await waitFor(() => {
expect(screen.getByText('Wing')).toBeInTheDocument();
});
await user.click(screen.getByRole('button', { name: /Sub-projects of Airframe/ }));
await waitFor(() => {
expect(screen.queryByText('Wing')).not.toBeInTheDocument();
});
expect(screen.getByText('Bulkhead')).toBeInTheDocument();
// A per-group chevron is a passing choice, not a new default for the
// whole page.
expect(setItemMock).not.toHaveBeenCalledWith('projects-collapse-subprojects', 'true');
});
it('remembers the default the pill sets', async () => {
getItemMock.mockReturnValue(null);
const user = userEvent.setup();
serveAirframe();
render();
await waitFor(() => {
expect(screen.getByText('Wing')).toBeInTheDocument();
});
await user.click(screen.getByRole('button', { name: 'Collapse' }));
await waitFor(() => {
expect(screen.queryByText('Wing')).not.toBeInTheDocument();
});
expect(setItemMock).toHaveBeenCalledWith('projects-collapse-subprojects', 'true');
});
it('drops per-group choices when the default is flipped against them', async () => {
getItemMock.mockReturnValue(null);
const user = userEvent.setup();
serveAirframe();
render();
await waitFor(() => {
expect(screen.getByText('Wing')).toBeInTheDocument();
});
// Shut this one by hand, then ask for everything shut, then everything
// open again. The hand-made choice was made against the old default,
// and honouring it now would leave a group defying the pill the user
// just pressed.
await user.click(screen.getByRole('button', { name: /Sub-projects of Airframe/ }));
await waitFor(() => {
expect(screen.queryByText('Wing')).not.toBeInTheDocument();
});
await user.click(screen.getByRole('button', { name: 'Collapse' }));
await user.click(screen.getByRole('button', { name: 'Collapse' }));
await waitFor(() => {
expect(screen.getByText('Wing')).toBeInTheDocument();
});
});
it('counts what the chevron actually unfolds, not the card badge', async () => {
// The API counts sub-projects across every status on purpose, so the
// card badge says 2 while the Active filter leaves one child to show.
// A caption repeating the badge would promise a card that is not there.
getItemMock.mockImplementation((key: string) =>
key === 'projects-collapse-subprojects' ? 'true' : null,
);
server.use(
http.get('/api/v1/projects/', ({ request }) => {
const status = new URL(request.url).searchParams.get('status');
const all = [
listItem({ id: 1, name: 'Airframe', child_count: 2 }),
listItem({ id: 2, name: 'Wing', parent_id: 1 }),
listItem({ id: 3, name: 'Tailplane', parent_id: 1, status: 'archived' }),
];
return HttpResponse.json(status ? all.filter((p) => p.status === status) : all);
}),
);
render();
const caption = await screen.findByRole('button', { name: /Sub-projects of Airframe/ });
expect(caption.textContent).toContain('1');
expect(caption.textContent).not.toContain('2');
});
it('gives a shut group one grid cell instead of a whole row', async () => {
// Folding a tree that still spends a full-width row per parent halves
// the scrolling at best; the point is to get the page back.
getItemMock.mockImplementation((key: string) =>
key === 'projects-collapse-subprojects' ? 'true' : null,
);
serveAirframe();
render();
const caption = await screen.findByRole('button', { name: /Sub-projects of Airframe/ });
expect(caption.closest('.col-span-full')).toBeNull();
});
it('offers no pill on a page where nothing is nested', async () => {
getItemMock.mockReturnValue(null);
render();
await waitFor(() => {
expect(screen.getByText('Functional Parts')).toBeInTheDocument();
});
// A control that cannot change anything is just one more thing to read.
expect(screen.queryByRole('button', { name: 'Collapse' })).not.toBeInTheDocument();
});
});
});
});