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
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
import { describe, expect, it } from 'vitest';

import { ApiError } from '../../api-client';
import { composedFallbackNotice, selectComposedTemplate } from '../fallback';
import { splitTemplateAroundContentSlot } from '../split';
import type { AppliedTemplate } from '../api';
import type { AppliedTemplateState } from '../use-applied-template';

const unknownSlug: AppliedTemplateState = {
status: 'missing',
missing: { status: 'missing', reason: 'unknown-slug', slug: 'landing-xl' },
};

const emptyTemplate: AppliedTemplateState = {
status: 'missing',
missing: { status: 'missing', reason: 'empty' },
};

const networkError: AppliedTemplateState = {
status: 'error',
error: new ApiError(
'Applied-template request failed with status 500',
500,
null
),
};

const resolved: AppliedTemplateState = {
status: 'ok',
template: {
status: 'ok',
slug: 'single-post',
name: 'Single Post',
source: 'db',
blocks: [],
template_parts: {},
} as AppliedTemplate,
};

describe('selectComposedTemplate', () => {
it('passes a resolved template straight through', () => {
const selection = selectComposedTemplate(resolved);

expect(selection).not.toBeNull();
expect(selection?.name).toBe('Single Post');
expect(selection?.slug).toBe('single-post');
expect(selection?.fallbackReason).toBeNull();
});

it.each([
['an unresolvable slug', unknownSlug, 'unknown-slug'],
['an empty template field', emptyTemplate, 'empty'],
['a network error', networkError, 'error'],
])('routes %s to the default template', (_label, state, reason) => {
const selection = selectComposedTemplate(state);

expect(selection).not.toBeNull();
expect(selection?.fallbackReason).toBe(reason);
expect(selection?.slug).toBeNull();
expect(selection?.name).toBe('Default template');
expect(selection?.blocks.map((block) => block.name)).toEqual([
'artisanpack/post-title',
'artisanpack/post-featured-image',
'artisanpack/post-content',
]);
});

it('splits the default template around its content slot', () => {
const selection = selectComposedTemplate(emptyTemplate);
const split = splitTemplateAroundContentSlot(
selection?.blocks ?? [],
selection?.name ?? ''
);

expect(split.slotFound).toBe(true);
expect(split.header.map((block) => block.name)).toEqual([
'artisanpack/post-title',
'artisanpack/post-featured-image',
]);
expect(split.footer).toEqual([]);
});

it('hands out a fresh block tree per call', () => {
const first = selectComposedTemplate(emptyTemplate);
const second = selectComposedTemplate(emptyTemplate);

expect(first?.blocks[0]).not.toBe(second?.blocks[0]);
});

it.each([
['idle', { status: 'idle' } as AppliedTemplateState],
['loading', { status: 'loading' } as AppliedTemplateState],
])('composes nothing while %s', (_label, state) => {
expect(selectComposedTemplate(state)).toBeNull();
});
});

describe('composedFallbackNotice', () => {
it('names the unavailable template for an unresolvable slug', () => {
expect(composedFallbackNotice(unknownSlug)).toEqual({
tone: 'warning',
message:
'The template “landing-xl” is unavailable — previewing on the default template.',
});
});

it('uses the no-template copy for an empty template field', () => {
expect(composedFallbackNotice(emptyTemplate)).toEqual({
tone: 'warning',
message:
'No template is set for this content — previewing on the default template.',
});
});

it('falls back to the no-template copy when the miss carries no slug', () => {
const notice = composedFallbackNotice({
status: 'missing',
missing: { status: 'missing', reason: 'unknown-slug' },
});

expect(notice?.message).toBe(
'No template is set for this content — previewing on the default template.'
);
});

it('reads a network error as an error, not a routine miss', () => {
expect(composedFallbackNotice(networkError)).toEqual({
tone: 'error',
message:
'The template could not be loaded — previewing on the default template.',
});
});

it('says nothing when a real template resolved', () => {
expect(composedFallbackNotice(resolved)).toBeNull();
expect(composedFallbackNotice({ status: 'loading' })).toBeNull();
expect(composedFallbackNotice({ status: 'idle' })).toBeNull();
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,219 @@
import { render, screen, waitFor } from '@testing-library/react';
import { ToastProvider } from '@artisanpack-ui/react/feedback';
import { describe, expect, it } from 'vitest';

import { ApiError } from '../../api-client';
import { useComposedFallbackToast } from '../use-fallback-toast';
import type { AppliedTemplateState } from '../use-applied-template';

interface HarnessProps {
viewMode: 'content' | 'with-template';
state: AppliedTemplateState;
}

function Harness(props: HarnessProps): JSX.Element {
useComposedFallbackToast(props);

return <div data-testid="ap-composed-fallback-harness" />;
}

function renderHarness(props: HarnessProps) {
const result = render(
<ToastProvider>
<Harness {...props} />
</ToastProvider>
);

return {
...result,
rerenderHarness: (next: HarnessProps): void => {
result.rerender(
<ToastProvider>
<Harness {...next} />
</ToastProvider>
);
},
};
}

const unknownSlug: AppliedTemplateState = {
status: 'missing',
missing: { status: 'missing', reason: 'unknown-slug', slug: 'landing-xl' },
};

const emptyTemplate: AppliedTemplateState = {
status: 'missing',
missing: { status: 'missing', reason: 'empty' },
};

const networkError: AppliedTemplateState = {
status: 'error',
error: new ApiError('boom', 500, null),
};

const unavailableCopy =
'The template “landing-xl” is unavailable — previewing on the default template.';

describe('useComposedFallbackToast', () => {
it('names the unavailable template when the slug does not resolve', async () => {
const { rerenderHarness } = renderHarness({
viewMode: 'content',
state: { status: 'idle' },
});

rerenderHarness({ viewMode: 'with-template', state: unknownSlug });

await waitFor(() => {
expect(screen.getByText(unavailableCopy)).toBeInTheDocument();
});
});

it('uses the no-template copy when the content has no template field', async () => {
const { rerenderHarness } = renderHarness({
viewMode: 'content',
state: { status: 'idle' },
});

rerenderHarness({ viewMode: 'with-template', state: emptyTemplate });

await waitFor(() => {
expect(
screen.getByText(
'No template is set for this content — previewing on the default template.'
)
).toBeInTheDocument();
});
});

it('announces a network error separately from a routine miss', async () => {
const { rerenderHarness } = renderHarness({
viewMode: 'content',
state: { status: 'idle' },
});

rerenderHarness({ viewMode: 'with-template', state: networkError });

await waitFor(() => {
expect(
screen.getByText(
'The template could not be loaded — previewing on the default template.'
)
).toBeInTheDocument();
});
});

it('fires exactly once per toggle-on event', async () => {
const { rerenderHarness } = renderHarness({
viewMode: 'content',
state: { status: 'idle' },
});

rerenderHarness({ viewMode: 'with-template', state: { status: 'loading' } });
rerenderHarness({ viewMode: 'with-template', state: unknownSlug });

await waitFor(() => {
expect(screen.getByText(unavailableCopy)).toBeInTheDocument();
});

// Re-renders while still toggled on must not stack a second toast.
rerenderHarness({ viewMode: 'with-template', state: unknownSlug });
rerenderHarness({ viewMode: 'with-template', state: unknownSlug });

await waitFor(() => {
expect(screen.getAllByText(unavailableCopy)).toHaveLength(1);
});
});

it('announces a different failure without leaving composed mode', async () => {
const { rerenderHarness } = renderHarness({
viewMode: 'with-template',
state: unknownSlug,
});

await waitFor(() => {
expect(screen.getByText(unavailableCopy)).toBeInTheDocument();
});

// Author picks a second, also-broken template from the document
// panel: a refetch, then a different miss. Silence here would read
// as the template change having done nothing at all.
rerenderHarness({
viewMode: 'with-template',
state: { status: 'loading' },
});
rerenderHarness({
viewMode: 'with-template',
state: {
status: 'missing',
missing: {
status: 'missing',
reason: 'unknown-slug',
slug: 'archive-wide',
},
},
});

await waitFor(() => {
expect(
screen.getByText(
'The template “archive-wide” is unavailable — previewing on the default template.'
)
).toBeInTheDocument();
});
});

it('announces again after the toggle is flipped off and back on', async () => {
const { rerenderHarness } = renderHarness({
viewMode: 'with-template',
state: unknownSlug,
});

await waitFor(() => {
expect(screen.getByText(unavailableCopy)).toBeInTheDocument();
});

rerenderHarness({ viewMode: 'content', state: unknownSlug });
rerenderHarness({ viewMode: 'with-template', state: unknownSlug });

// The first toast may still be on screen (auto-dismiss is on a
// timer), so the second toggle-on shows as a second copy rather
// than a replacement.
await waitFor(() => {
expect(
screen.getAllByText(unavailableCopy).length
).toBeGreaterThan(1);
});
});

it('stays silent when a real template resolves', async () => {
const { rerenderHarness } = renderHarness({
viewMode: 'content',
state: { status: 'idle' },
});

rerenderHarness({
viewMode: 'with-template',
state: {
status: 'ok',
template: {
status: 'ok',
slug: 'single-post',
name: 'Single Post',
source: 'db',
blocks: [],
template_parts: {},
},
},
});

await waitFor(() => {
expect(
screen.getByTestId('ap-composed-fallback-harness')
).toBeInTheDocument();
});

expect(screen.queryByText(/previewing on the default template/)).toBe(
null
);
});
});
Loading