Files
nexu-io__open-design/apps/web/tests/components/InlineModelSwitcher.compact-model-click.test.tsx
Ray Xi b5c9723aca revert(web): take the Home entry refresh (#7730, #7731) off main pending the feature branch (#7843)
* Revert "feat(web): add recent projects and run status to the entry rail (#7730)"

This reverts commit d4138ea818.

* Revert "feat(web): refresh the Home hero and composer (#7731)"

This reverts commit 2e4c1a753b.
2026-09-07 11:34:23 +00:00

343 lines
13 KiB
TypeScript

// @vitest-environment jsdom
//
// Home composer chip → compact model list → click a model. The chip MUST show
// the clicked model.
//
// The reported bug: the owner clicked `claude-opus-4.6` in the compact list and
// the chip stayed on `deepseek-v4-flash`. The list offered a model that is above
// the caller's plan (`enabled: false` from `vela model list --json`) as a plain
// selectable row; the click was accepted, written to config, and then coerced
// straight back by `normalizeAgentModelChoice` + the re-normalization effect in
// `InlineModelSwitcher` — so the click read as a no-op.
//
// Every assertion here is on the user-visible chip label, never on whether a
// callback fired: the callback fires today and the chip still does not move.
// The switcher does not own persistence — `App.handleAgentModelChange` does — so
// these specs wire the real merge/persist shape around the component and let the
// new config flow back in as a prop. A bare `vi.fn()` callback would make every
// case below pass while the real client stayed broken.
import { useRef, useState } from 'react';
import { cleanup, fireEvent, render, screen, within } from '@testing-library/react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { mergeAgentModelChoice } from '../../src/App';
import { DEEPSEEK_V4_FLASH_CAMPAIGN } from '../../src/campaigns/deepseek-v4-flash';
import { InlineModelSwitcher } from '../../src/components/InlineModelSwitcher';
import type { AgentInfo, AppConfig } from '../../src/types';
vi.mock('../../src/providers/provider-models', () => ({
fetchProviderModels: vi.fn(async () => ({ ok: false, models: [] })),
}));
const baseConfig: AppConfig = {
mode: 'daemon',
apiKey: '',
apiProtocol: 'anthropic',
apiVersion: '',
baseUrl: 'https://api.anthropic.com',
model: 'claude-sonnet-4-5',
apiProviderBaseUrl: 'https://api.anthropic.com',
apiProtocolConfigs: {},
agentId: 'amr',
skillId: null,
designSystemId: null,
onboardingCompleted: true,
mediaProviders: {},
agentModels: {},
agentCliEnv: {},
};
/** The catalog from the bug report: the two DeepSeek tiers are on the caller's
* plan, the Claude tiers are not. `enabled: false` is what `vela model list
* --json` reports for a model above the caller's plan. */
const amrAgent: AgentInfo = {
id: 'amr',
name: 'AMR (vela)',
bin: 'amr',
available: true,
version: '1.0.0',
models: [
{ id: 'deepseek-v4-flash', label: 'deepseek-v4-flash', enabled: true, default: true },
{ id: 'deepseek-v4-pro', label: 'deepseek-v4-pro', enabled: true },
{ id: 'claude-fable-5', label: 'claude-fable-5', enabled: false },
{ id: 'claude-opus-4.6', label: 'claude-opus-4.6', enabled: false },
{ id: 'claude-opus-4.7', label: 'claude-opus-4.7', enabled: false },
{ id: 'claude-opus-4.8', label: 'claude-opus-4.8', enabled: false },
],
};
/** Same catalog with every model on-plan — the control case. */
const amrAgentAllEnabled: AgentInfo = {
...amrAgent,
models: (amrAgent.models ?? []).map((model) => ({ ...model, enabled: true })),
};
/**
* Mirrors `App.handleAgentModelChange`: merge into the persisted choice, keep a
* ref as the write-through truth source, and re-render the switcher with the new
* config. This is the round-trip the chip label actually depends on, and the
* same round-trip a page reload replays out of the persisted config.
*/
function StatefulSwitcher({
agents,
initialConfig,
compact = true,
onPersist,
}: {
agents: AgentInfo[];
initialConfig?: Partial<AppConfig>;
compact?: boolean;
onPersist?: (agentId: string, model: string | undefined) => void;
}) {
const [config, setConfig] = useState<AppConfig>({ ...baseConfig, ...initialConfig });
const persistedRef = useRef(config);
return (
<InlineModelSwitcher
config={config}
agents={agents}
providerModelsCache={{}}
compact={compact}
daemonLive
onModeChange={vi.fn()}
onAgentChange={vi.fn()}
onAgentModelChange={(agentId, choice) => {
const current = persistedRef.current;
const merged = mergeAgentModelChoice(
current.agentModels?.[agentId] ?? {},
choice,
);
const next: AppConfig = {
...current,
agentModels: { ...(current.agentModels ?? {}), [agentId]: merged },
};
persistedRef.current = next;
onPersist?.(agentId, merged.model);
setConfig(next);
}}
onApiProtocolChange={vi.fn()}
onApiModelChange={vi.fn()}
onOpenSettings={vi.fn()}
/>
);
}
function chipText(): string {
return screen.getByTestId('inline-model-switcher-chip').textContent ?? '';
}
function openSwitcher(): HTMLElement {
fireEvent.click(screen.getByTestId('inline-model-switcher-chip'));
return screen.getByTestId('inline-model-switcher-popover');
}
function compactRow(modelId: string): HTMLElement {
return screen.getByTestId(`inline-model-switcher-compact-model-${modelId}`);
}
function isOffered(row: HTMLElement): boolean {
return (
!(row as HTMLButtonElement).disabled && row.getAttribute('aria-disabled') !== 'true'
);
}
afterEach(() => {
cleanup();
vi.unstubAllGlobals();
vi.restoreAllMocks();
});
/** Pins the clock inside/outside the real campaign window — the ONLY lever
* left for campaign visibility now that the URL review parameters are gone. */
function mockNow(at: string): void {
vi.spyOn(Date, 'now').mockReturnValue(Date.parse(at));
}
describe('compact home model list — a clicked model reaches the chip', () => {
it('never offers a model whose click the chip will not honor', () => {
// The invariant, stated as the user sees it. Any row the list presents as
// clickable must move the chip; a row it will not honor must be presented
// as unavailable with a reason. Today `claude-opus-4.6` (and the other
// off-plan Claude tiers) are offered as plain rows and clicking them leaves
// the chip on `deepseek-v4-flash`.
for (const model of amrAgent.models ?? []) {
render(<StatefulSwitcher agents={[amrAgent]} />);
expect(chipText()).toContain('deepseek-v4-flash');
openSwitcher();
const row = compactRow(model.id);
const offered = isOffered(row);
fireEvent.click(row);
if (offered) {
expect(chipText(), `${model.id} was offered as selectable`).toContain(
model.label,
);
} else {
// Refused is fine — silently refused is not. The row must say why.
expect(
screen.getByTestId(`inline-model-switcher-compact-model-lock-${model.id}`)
.textContent,
`${model.id} was refused without a reason`,
).toBeTruthy();
expect(chipText()).toContain('deepseek-v4-flash');
}
cleanup();
}
});
it('applies an on-plan model the user clicks', () => {
// Control case: this passes on the unfixed code. It is what rules out the
// outside-click/`mousedown` close handler as the cause — if that handler
// were unmounting the list before the option's click fired, this case would
// fail too.
render(<StatefulSwitcher agents={[amrAgentAllEnabled]} />);
expect(chipText()).toContain('deepseek-v4-flash');
openSwitcher();
fireEvent.click(compactRow('deepseek-v4-pro'));
expect(chipText()).toContain('deepseek-v4-pro');
expect(screen.queryByTestId('inline-model-switcher-popover')).toBeNull();
});
it('lists available models above the ones the plan does not include', () => {
// Same ordering the execution-settings picker already applies via
// `orderModelOptionsByAvailability`. The compact list was the one surface
// rendering the raw catalog order, so off-plan models could sit on top of
// the ones the caller can actually pick.
const interleaved: AgentInfo = {
...amrAgent,
models: [
{ id: 'claude-opus-4.8', label: 'claude-opus-4.8', enabled: false },
{ id: 'deepseek-v4-flash', label: 'deepseek-v4-flash', enabled: true, default: true },
{ id: 'claude-opus-4.6', label: 'claude-opus-4.6', enabled: false },
{ id: 'deepseek-v4-pro', label: 'deepseek-v4-pro', enabled: true },
],
};
render(<StatefulSwitcher agents={[interleaved]} />);
const popover = openSwitcher();
const labels = within(popover)
.getAllByRole('radio')
.map((row) => row.textContent ?? '');
expect(labels[0]).toContain('deepseek-v4-flash');
expect(labels[1]).toContain('deepseek-v4-pro');
expect(labels.slice(2).join('\n')).not.toContain('deepseek');
});
it('writes nothing at all when a refused model is clicked', () => {
// The refusal happens BEFORE the write, not after: without the gate the
// click lands a locked model in the config (which `saveConfig` persists and
// `syncConfigToDaemon` pushes to the daemon) and a second write reverts it.
// Two round-trips to store a value the user never gets.
const persisted = vi.fn();
render(<StatefulSwitcher agents={[amrAgent]} onPersist={persisted} />);
openSwitcher();
fireEvent.click(compactRow('claude-opus-4.6'));
expect(persisted).not.toHaveBeenCalled();
expect(chipText()).toContain('deepseek-v4-flash');
});
it('re-selecting the already active model closes the list without changing the chip', () => {
render(<StatefulSwitcher agents={[amrAgentAllEnabled]} />);
openSwitcher();
fireEvent.click(compactRow('deepseek-v4-flash'));
expect(chipText()).toContain('deepseek-v4-flash');
expect(screen.queryByTestId('inline-model-switcher-popover')).toBeNull();
});
it('does not show unlimited labels during the campaign window', () => {
// Campaign visibility is decided by the real window alone: pin the clock
// inside the window instead of the removed ?campaign= review parameters.
mockNow(DEEPSEEK_V4_FLASH_CAMPAIGN.window.startAt);
render(<StatefulSwitcher agents={[amrAgentAllEnabled]} />);
expect(chipText()).toContain('deepseek-v4-flash');
expect(within(screen.getByTestId('inline-model-switcher-chip')).queryByText('Unlimited'))
.toBeNull();
const popover = openSwitcher();
expect(within(compactRow('deepseek-v4-pro')).queryByText('Unlimited')).toBeNull();
expect(within(compactRow('deepseek-v4-flash')).queryByText('Unlimited')).toBeNull();
expect(within(popover).queryByText('Unlimited')).toBeNull();
});
it('hides the campaign badge entirely outside the real window', () => {
// The half-open window: at endAtExclusive the campaign is over, and no
// URL parameter can bring the badge back.
mockNow(DEEPSEEK_V4_FLASH_CAMPAIGN.window.endAtExclusive);
render(<StatefulSwitcher agents={[amrAgentAllEnabled]} />);
expect(chipText()).toContain('deepseek-v4-flash');
expect(within(screen.getByTestId('inline-model-switcher-chip')).queryByText('Unlimited'))
.toBeNull();
const popover = openSwitcher();
expect(within(popover).queryByText('Unlimited')).toBeNull();
});
it('never applies the AMR campaign badge to a BYOK model', () => {
// BYOK deliberately retains the last local-agent model in `agentModels` so
// switching back to local execution restores it. That dormant AMR choice
// must not decorate the visible BYOK model: BYOK usage is charged by the
// user's own provider and is outside this hosted-model campaign.
mockNow(DEEPSEEK_V4_FLASH_CAMPAIGN.window.startAt);
render(
<StatefulSwitcher
agents={[amrAgentAllEnabled]}
initialConfig={{
mode: 'api',
model: 'grok-4.5',
agentId: 'amr',
agentModels: {
amr: { model: DEEPSEEK_V4_FLASH_CAMPAIGN.modelId },
},
}}
/>,
);
const chip = screen.getByTestId('inline-model-switcher-chip');
expect(chip).toHaveTextContent('grok-4.5');
expect(within(chip).queryByText('Unlimited')).toBeNull();
});
it('still closes on a click genuinely outside the switcher', () => {
render(
<div>
<StatefulSwitcher agents={[amrAgentAllEnabled]} />
<button type="button" data-testid="outside">
outside
</button>
</div>,
);
openSwitcher();
fireEvent.mouseDown(screen.getByTestId('outside'));
expect(screen.queryByTestId('inline-model-switcher-popover')).toBeNull();
});
it('keeps the execution-settings picker applying a model', () => {
// The non-compact surface the existing portal-class exemption protects. It
// must keep working: its option list is a `document.body` portal, so its
// clicks land outside the switcher's wrapper.
render(<StatefulSwitcher agents={[amrAgentAllEnabled]} compact={false} />);
openSwitcher();
fireEvent.click(screen.getByTestId('inline-model-switcher-agent-model'));
const modelPopover = screen.getByTestId(
'inline-model-switcher-agent-model-popover',
);
fireEvent.mouseDown(modelPopover);
fireEvent.click(
within(modelPopover).getByRole('option', { name: /deepseek-v4-pro/ }),
);
expect(chipText()).toContain('deepseek-v4-pro');
expect(screen.getByTestId('inline-model-switcher-agent-model')).toHaveTextContent(
'deepseek-v4-pro',
);
});
});