|
@@ -195,20 +195,44 @@ function scriptedFace(overrides: {
|
|
|
|
|
|
|
|
type WireFace = ConstructorParameters<typeof ModelsSettingsStore>[0]
|
|
type WireFace = ConstructorParameters<typeof ModelsSettingsStore>[0]
|
|
|
|
|
|
|
|
|
|
+/** One recorded child-slot dispatch: seat name, owner share, kind options. */
|
|
|
|
|
+type RenderSlotCall = [name: string, owner: Record<string, unknown>, opts?: { entryKey?: string }]
|
|
|
|
|
+
|
|
|
|
|
+/** Child-slot dispatch stub: records every seat occurrence, renders nothing. */
|
|
|
|
|
+function stubRenderSlot() {
|
|
|
|
|
+ return vi.fn((..._call: RenderSlotCall) => null)
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/** The provider-card seat dispatches a stub recorded, as (route id, configured, keyConfigured, entryKey). */
|
|
|
|
|
+function cardSeatCalls(
|
|
|
|
|
+ renderSlot: ReturnType<typeof stubRenderSlot>,
|
|
|
|
|
+): Array<[string, boolean, boolean, string | undefined]> {
|
|
|
|
|
+ return renderSlot.mock.calls
|
|
|
|
|
+ .filter(call => call[0] === 'settings.models.provider-card')
|
|
|
|
|
+ .map(call => [
|
|
|
|
|
+ (call[1] as { provider: { provider: string } }).provider.provider,
|
|
|
|
|
+ (call[1] as { configured: boolean }).configured,
|
|
|
|
|
+ (call[1] as { keyConfigured: boolean }).keyConfigured,
|
|
|
|
|
+ call[2]?.entryKey,
|
|
|
|
|
+ ])
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
async function mountFace(scripted: ReturnType<typeof scriptedFace>) {
|
|
async function mountFace(scripted: ReturnType<typeof scriptedFace>) {
|
|
|
const { face, update, replace, mutate, set, unset } = scripted
|
|
const { face, update, replace, mutate, set, unset } = scripted
|
|
|
const mirror = new SettingsDescribeMirror(face as never)
|
|
const mirror = new SettingsDescribeMirror(face as never)
|
|
|
const controller = new ModelsSettingsStore(face as unknown as WireFace, settingsSchema, mirror)
|
|
const controller = new ModelsSettingsStore(face as unknown as WireFace, settingsSchema, mirror)
|
|
|
await controller.load()
|
|
await controller.load()
|
|
|
|
|
+ const renderSlot = stubRenderSlot()
|
|
|
const injected: ModelsSectionProps = {
|
|
const injected: ModelsSectionProps = {
|
|
|
controller,
|
|
controller,
|
|
|
useSnapshot: bindSnapshotSelector(controller.store),
|
|
useSnapshot: bindSnapshotSelector(controller.store),
|
|
|
api: face as never,
|
|
api: face as never,
|
|
|
schema: settingsSchema,
|
|
schema: settingsSchema,
|
|
|
t,
|
|
t,
|
|
|
|
|
+ renderSlot: renderSlot as unknown as ModelsSectionProps['renderSlot'],
|
|
|
}
|
|
}
|
|
|
const view = render(<ModelsSection {...injected} />)
|
|
const view = render(<ModelsSection {...injected} />)
|
|
|
- return { view, face, update, replace, mutate, set, unset, controller, mirror }
|
|
|
|
|
|
|
+ return { view, face, update, replace, mutate, set, unset, controller, mirror, renderSlot }
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
async function mountSection(overrides: Parameters<typeof scriptedFace>[0] = {}) {
|
|
async function mountSection(overrides: Parameters<typeof scriptedFace>[0] = {}) {
|
|
@@ -246,6 +270,61 @@ describe('ModelsSection', () => {
|
|
|
expect(document.body.textContent).toBe('')
|
|
expect(document.body.textContent).toBe('')
|
|
|
})
|
|
})
|
|
|
|
|
|
|
|
|
|
+ it('dispatches the provider-card seat per rendered row, keyed by the owning namespace', async () => {
|
|
|
|
|
+ const { renderSlot } = await mountSection()
|
|
|
|
|
+ const cards = cardSeatCalls(renderSlot)
|
|
|
|
|
+ expect(cards).toContainEqual(['openai', true, true, 'llm-pi-ai'])
|
|
|
|
|
+ expect(cards).toContainEqual(['deepseek-official', true, false, 'llm-deepseek'])
|
|
|
|
|
+ // The footer seat renders once below the rows and the add controls.
|
|
|
|
|
+ expect(renderSlot.mock.calls.filter(call => call[0] === 'settings.models.footer')).toEqual([
|
|
|
|
|
+ ['settings.models.footer', {}],
|
|
|
|
|
+ ])
|
|
|
|
|
+ })
|
|
|
|
|
+
|
|
|
|
|
+ it('dispatches the provider-card seat inside the first-run setup card', async () => {
|
|
|
|
|
+ const { renderSlot } = await mountFirstRun()
|
|
|
|
|
+ expect(cardSeatCalls(renderSlot)).toContainEqual(['deepseek-official', true, false, 'llm-deepseek'])
|
|
|
|
|
+ })
|
|
|
|
|
+
|
|
|
|
|
+ it('dispatches the provider-card seat on the add-provider draft with its dormant row', async () => {
|
|
|
|
|
+ const { renderSlot } = await mountSection()
|
|
|
|
|
+ renderSlot.mockClear()
|
|
|
|
|
+ fireEvent.click(screen.getByRole('button', { name: en.add }))
|
|
|
|
|
+ expect(cardSeatCalls(renderSlot)).toContainEqual(['anthropic', false, false, 'llm-pi-ai'])
|
|
|
|
|
+ })
|
|
|
|
|
+
|
|
|
|
|
+ it('derives the draft seat\'s key fact from the page\'s conventional reference', async () => {
|
|
|
|
|
+ const scripted = scriptedFace()
|
|
|
|
|
+ scripted.face.credentials.describe.mockImplementation((payload: { refs: string[] }) => Promise.resolve(ok({
|
|
|
|
|
+ credentials: Object.fromEntries(payload.refs.map(ref => [ref, {
|
|
|
|
|
+ configured: ref === 'OPENAI_API_KEY' || ref === 'ANTHROPIC_API_KEY',
|
|
|
|
|
+ writable: true,
|
|
|
|
|
+ }])),
|
|
|
|
|
+ })))
|
|
|
|
|
+ const { renderSlot } = await mountFace(scripted)
|
|
|
|
|
+ renderSlot.mockClear()
|
|
|
|
|
+ fireEvent.click(screen.getByRole('button', { name: en.add }))
|
|
|
|
|
+ // The dormant row names no reference yet; the seat still reports the
|
|
|
|
|
+ // derived ANTHROPIC_API_KEY the editor itself displays as configured.
|
|
|
|
|
+ expect(cardSeatCalls(renderSlot)).toContainEqual(['anthropic', false, true, 'llm-pi-ai'])
|
|
|
|
|
+ })
|
|
|
|
|
+
|
|
|
|
|
+ it('skips the draft seat when a refresh drops the dormant row', async () => {
|
|
|
|
|
+ const { renderSlot, face, controller } = await mountSection()
|
|
|
|
|
+ fireEvent.click(screen.getByRole('button', { name: en.add }))
|
|
|
|
|
+ face.llm.providers.mockImplementation(() => Promise.resolve(ok({
|
|
|
|
|
+ providers: [
|
|
|
|
|
+ { provider: 'deepseek-official', displayName: 'DeepSeek', settingsNs: 'llm-deepseek', settingsPath: [], active: true },
|
|
|
|
|
+ { provider: 'openai', displayName: 'openai', settingsNs: 'llm-pi-ai', settingsPath: ['providers', 'openai'], active: true },
|
|
|
|
|
+ ],
|
|
|
|
|
+ })))
|
|
|
|
|
+ renderSlot.mockClear()
|
|
|
|
|
+ await act(async () => { await controller.load() })
|
|
|
|
|
+ // The draft card is still open while its row is gone from the directory.
|
|
|
|
|
+ expect(screen.getByLabelText(en.keyInput)).toBeTruthy()
|
|
|
|
|
+ expect(cardSeatCalls(renderSlot).some(([provider]) => provider === 'anthropic')).toBe(false)
|
|
|
|
|
+ })
|
|
|
|
|
+
|
|
|
it('persists the default-off subagent model-selection switch for new sessions', async () => {
|
|
it('persists the default-off subagent model-selection switch for new sessions', async () => {
|
|
|
const enabledNamespace: SettingsNamespaceView = {
|
|
const enabledNamespace: SettingsNamespaceView = {
|
|
|
...wireNamespaces().find(view => view.ns === 'subagent-model-selection')!,
|
|
...wireNamespaces().find(view => view.ns === 'subagent-model-selection')!,
|
|
@@ -352,6 +431,7 @@ describe('ModelsSection', () => {
|
|
|
api={face as never}
|
|
api={face as never}
|
|
|
schema={settingsSchema}
|
|
schema={settingsSchema}
|
|
|
t={t}
|
|
t={t}
|
|
|
|
|
+ renderSlot={() => null}
|
|
|
/>)
|
|
/>)
|
|
|
|
|
|
|
|
const missing = screen.getByRole('img', { name: en.credentialMissing })
|
|
const missing = screen.getByRole('img', { name: en.credentialMissing })
|
|
@@ -376,6 +456,7 @@ describe('ModelsSection', () => {
|
|
|
api={face as never}
|
|
api={face as never}
|
|
|
schema={settingsSchema}
|
|
schema={settingsSchema}
|
|
|
t={t}
|
|
t={t}
|
|
|
|
|
+ renderSlot={() => null}
|
|
|
/>)
|
|
/>)
|
|
|
// Now a row with an Edit button, not an open card.
|
|
// Now a row with an Edit button, not an open card.
|
|
|
expect(screen.getAllByText(en.edit).length).toBeGreaterThan(1)
|
|
expect(screen.getAllByText(en.edit).length).toBeGreaterThan(1)
|
|
@@ -1106,6 +1187,7 @@ describe('ModelsSection', () => {
|
|
|
api={face as never}
|
|
api={face as never}
|
|
|
schema={settingsSchema}
|
|
schema={settingsSchema}
|
|
|
t={t}
|
|
t={t}
|
|
|
|
|
+ renderSlot={() => null}
|
|
|
/>)
|
|
/>)
|
|
|
const key = await screen.findByLabelText<HTMLInputElement>(en.keyInput)
|
|
const key = await screen.findByLabelText<HTMLInputElement>(en.keyInput)
|
|
|
expect(key.placeholder).toBe(en.keyPlaceholder)
|
|
expect(key.placeholder).toBe(en.keyPlaceholder)
|
|
@@ -1245,6 +1327,7 @@ describe('ModelsSection', () => {
|
|
|
api={face.face as never}
|
|
api={face.face as never}
|
|
|
schema={settingsSchema}
|
|
schema={settingsSchema}
|
|
|
t={t}
|
|
t={t}
|
|
|
|
|
+ renderSlot={() => null}
|
|
|
/>)
|
|
/>)
|
|
|
expect(screen.getByText(/directory down/)).toBeTruthy()
|
|
expect(screen.getByText(/directory down/)).toBeTruthy()
|
|
|
fireEvent.click(screen.getByText(en.retry))
|
|
fireEvent.click(screen.getByText(en.retry))
|
|
@@ -1267,6 +1350,7 @@ describe('ModelsSection', () => {
|
|
|
api={face as never}
|
|
api={face as never}
|
|
|
schema={settingsSchema}
|
|
schema={settingsSchema}
|
|
|
t={t}
|
|
t={t}
|
|
|
|
|
+ renderSlot={() => null}
|
|
|
/>)
|
|
/>)
|
|
|
expect(screen.getByText(en.readOnly)).toBeTruthy()
|
|
expect(screen.getByText(en.readOnly)).toBeTruthy()
|
|
|
expect(screen.getAllByText<HTMLButtonElement>(en.remove).every(button => button.disabled)).toBe(true)
|
|
expect(screen.getAllByText<HTMLButtonElement>(en.remove).every(button => button.disabled)).toBe(true)
|
|
@@ -1328,6 +1412,7 @@ describe('ModelsSection', () => {
|
|
|
api={face as never}
|
|
api={face as never}
|
|
|
schema={settingsSchema}
|
|
schema={settingsSchema}
|
|
|
t={t}
|
|
t={t}
|
|
|
|
|
+ renderSlot={() => null}
|
|
|
/>)
|
|
/>)
|
|
|
await screen.findByText('DeepSeek')
|
|
await screen.findByText('DeepSeek')
|
|
|
})
|
|
})
|