|
|
@@ -8,6 +8,9 @@ import type { RpcResponse, SettingsNamespaceView } from '@deepseek-ai/dsh-client
|
|
|
import { ModelsSection, needsSetup, removeProviderProfile } from '../src/client/ModelsSection.tsx'
|
|
|
import type { ModelsSectionInjected, ModelsSectionProps } from '../src/client/ModelsSection.tsx'
|
|
|
import { pathOps } from '../src/client/ProviderEditor.tsx'
|
|
|
+import {
|
|
|
+ DeepSeekModelsEditor, formatCapacity, modelDrafts, parseCapacity, validateDeepSeekModels,
|
|
|
+} from '../src/client/DeepSeekModelsEditor.tsx'
|
|
|
import { deriveKeyRef, ModelsSettingsStore } from '../src/client/store.ts'
|
|
|
import type { ProviderRow } from '../src/client/store.ts'
|
|
|
import { en } from '../src/client/locales.ts'
|
|
|
@@ -16,6 +19,16 @@ afterEach(cleanup)
|
|
|
|
|
|
const t: ModelsSectionInjected['t'] = key => en[key]
|
|
|
|
|
|
+/** Open one row's capacity disclosure (1-based, as the labels read). */
|
|
|
+function expandRow(position: number): void {
|
|
|
+ fireEvent.click(screen.getByLabelText(`${en.modelAdvanced} ${String(position)}`))
|
|
|
+}
|
|
|
+
|
|
|
+/** The capacity inputs of every open row, in row order. */
|
|
|
+function capacityInputs(label: string): HTMLInputElement[] {
|
|
|
+ return screen.getAllByLabelText<HTMLInputElement>(new RegExp(label))
|
|
|
+}
|
|
|
+
|
|
|
const PiAiConfig = Schema.object({
|
|
|
token: Schema.string().role('secret'),
|
|
|
providers: Schema.dict(Schema.object({
|
|
|
@@ -32,15 +45,54 @@ const DeepSeekConfig = Schema.object({
|
|
|
apiKeyEnv: Schema.string().role('credential-ref'),
|
|
|
baseURL: Schema.string().pattern(/^https:\/\//),
|
|
|
reasoningEffort: Schema.union(['off', 'high', 'max']),
|
|
|
+ defaultContextWindow: Schema.number().step(1).min(1),
|
|
|
+ models: Schema.array(Schema.object({
|
|
|
+ id: Schema.string().required(),
|
|
|
+ name: Schema.string(),
|
|
|
+ description: Schema.string(),
|
|
|
+ contextWindow: Schema.number().step(1).min(1),
|
|
|
+ // The adapter declares its catalog as a schema default rather than a
|
|
|
+ // composition entry, which is what the restore-defaults path has to read.
|
|
|
+ })).default([
|
|
|
+ {
|
|
|
+ id: 'deepseek-v4-flash',
|
|
|
+ name: 'DeepSeek-V4-Flash',
|
|
|
+ description: '',
|
|
|
+ contextWindow: 1_000_000,
|
|
|
+ },
|
|
|
+ {
|
|
|
+ id: 'deepseek-v4-pro',
|
|
|
+ name: 'DeepSeek-V4-Pro',
|
|
|
+ description: '',
|
|
|
+ contextWindow: 1_000_000,
|
|
|
+ },
|
|
|
+ ]),
|
|
|
})
|
|
|
|
|
|
+const DEFAULT_DEEPSEEK_MODELS = [
|
|
|
+ {
|
|
|
+ id: 'deepseek-v4-flash',
|
|
|
+ name: 'DeepSeek-V4-Flash',
|
|
|
+ description: 'Preserved hidden detail',
|
|
|
+ contextWindow: 1_000_000,
|
|
|
+ },
|
|
|
+ { id: 'deepseek-v4-pro', name: 'DeepSeek-V4-Pro', contextWindow: 1_000_000 },
|
|
|
+]
|
|
|
+
|
|
|
function wireNamespaces(): SettingsNamespaceView[] {
|
|
|
return [
|
|
|
{
|
|
|
ns: 'llm-deepseek',
|
|
|
schema: JSON.parse(JSON.stringify(DeepSeekConfig.toJSON())) as unknown,
|
|
|
- value: { apiKeyEnv: 'DEEPSEEK_API_KEY', baseURL: 'https://base', reasoningEffort: 'high' },
|
|
|
- base: {},
|
|
|
+ value: {
|
|
|
+ apiKeyEnv: 'DEEPSEEK_API_KEY',
|
|
|
+ baseURL: 'https://base',
|
|
|
+ reasoningEffort: 'high',
|
|
|
+ defaultContextWindow: 1_000_000,
|
|
|
+ maxTokens: 256_000,
|
|
|
+ models: DEFAULT_DEEPSEEK_MODELS,
|
|
|
+ },
|
|
|
+ base: { defaultContextWindow: 1_000_000, maxTokens: 256_000, models: DEFAULT_DEEPSEEK_MODELS },
|
|
|
user: { reasoningEffort: 'high' },
|
|
|
applies: 'live',
|
|
|
secrets: [{ path: ['apiKey'], set: false }],
|
|
|
@@ -244,6 +296,388 @@ describe('ModelsSection', () => {
|
|
|
})
|
|
|
})
|
|
|
|
|
|
+ it('materializes inherited models and adds an arbitrary DeepSeek id', async () => {
|
|
|
+ const { mutate } = await mountSection({
|
|
|
+ mutate: vi.fn(() => Promise.resolve(ok(wireNamespaces()[0]))),
|
|
|
+ })
|
|
|
+ fireEvent.click(screen.getByText(en.customized))
|
|
|
+ expect(screen.getByText(en.modelsInherited)).toBeTruthy()
|
|
|
+ expect(screen.getAllByLabelText(new RegExp(en.modelId)).map(input => (input as HTMLInputElement).value))
|
|
|
+ .toEqual(['deepseek-v4-flash', 'deepseek-v4-pro'])
|
|
|
+
|
|
|
+ fireEvent.click(screen.getByText(en.addModel))
|
|
|
+ const ids = screen.getAllByLabelText(new RegExp(en.modelId))
|
|
|
+ const names = screen.getAllByLabelText(new RegExp(en.modelName))
|
|
|
+ expandRow(3)
|
|
|
+ fireEvent.change(ids[2] as HTMLInputElement, { target: { value: 'private-preview' } })
|
|
|
+ fireEvent.change(names[2] as HTMLInputElement, { target: { value: 'Private Preview' } })
|
|
|
+ // Only row 3 is open, so its capacity is addressed by its own label.
|
|
|
+ fireEvent.change(screen.getByLabelText(`${en.contextWindow} 3`), { target: { value: '131072' } })
|
|
|
+ fireEvent.click(screen.getByText(en.apply))
|
|
|
+
|
|
|
+ await waitFor(() => { expect(mutate).toHaveBeenCalledTimes(1) })
|
|
|
+ expect(mutate.mock.calls[0]?.[0]).toEqual({
|
|
|
+ ns: 'llm-deepseek',
|
|
|
+ ops: [{
|
|
|
+ op: 'set',
|
|
|
+ path: ['models'],
|
|
|
+ value: [
|
|
|
+ ...DEFAULT_DEEPSEEK_MODELS,
|
|
|
+ { id: 'private-preview', name: 'Private Preview', contextWindow: 131_072 },
|
|
|
+ ],
|
|
|
+ }],
|
|
|
+ expectedRevision: 0,
|
|
|
+ })
|
|
|
+ })
|
|
|
+
|
|
|
+ it('rejects duplicate DeepSeek model ids before writing', async () => {
|
|
|
+ const { mutate } = await mountSection()
|
|
|
+ fireEvent.click(screen.getByText(en.customized))
|
|
|
+ fireEvent.click(screen.getByText(en.addModel))
|
|
|
+ const ids = screen.getAllByLabelText(new RegExp(en.modelId))
|
|
|
+ fireEvent.change(ids[2] as HTMLInputElement, { target: { value: 'deepseek-v4-flash' } })
|
|
|
+ fireEvent.click(screen.getByText(en.apply))
|
|
|
+
|
|
|
+ await screen.findByText(`Model 3: ${en.modelIdDuplicate}`)
|
|
|
+ expect(mutate).not.toHaveBeenCalled()
|
|
|
+ })
|
|
|
+
|
|
|
+ it('validates every adapter-owned model catalog invariant', () => {
|
|
|
+ expect(modelDrafts(undefined)).toEqual([])
|
|
|
+ expect(modelDrafts([null, 'bad', { id: 'ok' }])).toEqual([{}, {}, { id: 'ok' }])
|
|
|
+ expect(validateDeepSeekModels([{}])).toEqual({ index: 0, key: 'modelIdRequired' })
|
|
|
+ expect(validateDeepSeekModels([{ id: 'same' }, { id: 'same' }]))
|
|
|
+ .toEqual({ index: 1, key: 'modelIdDuplicate' })
|
|
|
+ expect(validateDeepSeekModels([{ id: 'model', name: '' }]))
|
|
|
+ .toEqual({ index: 0, key: 'modelNameInvalid' })
|
|
|
+ expect(validateDeepSeekModels([{ id: 'model', contextWindow: null }]))
|
|
|
+ .toEqual({ index: 0, key: 'modelContextInvalid' })
|
|
|
+ expect(validateDeepSeekModels([{ id: 'model', contextWindow: 1.5 }]))
|
|
|
+ .toEqual({ index: 0, key: 'modelContextInvalid' })
|
|
|
+ expect(validateDeepSeekModels([{ id: 'model', contextWindow: 0 }]))
|
|
|
+ .toEqual({ index: 0, key: 'modelContextInvalid' })
|
|
|
+ expect(validateDeepSeekModels([{ id: 'model', contextWindow: 1 }])).toBeUndefined()
|
|
|
+ expect(validateDeepSeekModels([{ id: 'model', maxTokens: null }]))
|
|
|
+ .toEqual({ index: 0, key: 'modelMaxTokensInvalid' })
|
|
|
+ expect(validateDeepSeekModels([{ id: 'model', maxTokens: 1.5 }]))
|
|
|
+ .toEqual({ index: 0, key: 'modelMaxTokensInvalid' })
|
|
|
+ expect(validateDeepSeekModels([{ id: 'model', maxTokens: 0 }]))
|
|
|
+ .toEqual({ index: 0, key: 'modelMaxTokensInvalid' })
|
|
|
+ expect(validateDeepSeekModels([{ id: 'model', maxTokens: 8192 }])).toBeUndefined()
|
|
|
+ })
|
|
|
+
|
|
|
+ it('reads context windows written as counts, thousands, or millions', () => {
|
|
|
+ expect(parseCapacity('')).toBeUndefined()
|
|
|
+ expect(parseCapacity(' ')).toBeUndefined()
|
|
|
+ expect(parseCapacity('131072')).toBe(131_072)
|
|
|
+ expect(parseCapacity(' 256K ')).toBe(256_000)
|
|
|
+ expect(parseCapacity('256k')).toBe(256_000)
|
|
|
+ expect(parseCapacity('1M')).toBe(1_000_000)
|
|
|
+ expect(parseCapacity('1m')).toBe(1_000_000)
|
|
|
+ // 1M is 1000K, not 1024K: capacities are quoted in decimal.
|
|
|
+ expect(parseCapacity('1M')).toBe(parseCapacity('1000K'))
|
|
|
+ // 2.3 * 1e6 is a few ULPs high in binary floating point; an integral
|
|
|
+ // intent must not become a fractional count the validator rejects.
|
|
|
+ expect(parseCapacity('2.3M')).toBe(2_300_000)
|
|
|
+ expect(Number.isInteger(parseCapacity('1.5M'))).toBe(true)
|
|
|
+ // A genuinely fractional count survives as one, for the validator to reject.
|
|
|
+ expect(parseCapacity('0.0001K')).toBeCloseTo(0.1)
|
|
|
+ expect(parseCapacity('abc')).toBeNaN()
|
|
|
+ expect(parseCapacity('1G')).toBeNaN()
|
|
|
+ expect(parseCapacity('1M1')).toBeNaN()
|
|
|
+ })
|
|
|
+
|
|
|
+ it('spells a stored count in the shortest form that round-trips', () => {
|
|
|
+ expect(formatCapacity(1_000_000)).toBe('1M')
|
|
|
+ expect(formatCapacity(256_000)).toBe('256K')
|
|
|
+ expect(formatCapacity(1_500_000)).toBe('1500K')
|
|
|
+ expect(formatCapacity(131_072)).toBe('131072')
|
|
|
+ // Values the validator will reject are shown as-is rather than dressed up.
|
|
|
+ expect(formatCapacity(Number.NaN)).toBe('NaN')
|
|
|
+ expect(formatCapacity(0)).toBe('0')
|
|
|
+ for (const text of ['1M', '256K', '131072', '1500K']) {
|
|
|
+ expect(formatCapacity(parseCapacity(text) as number)).toBe(text)
|
|
|
+ }
|
|
|
+ })
|
|
|
+
|
|
|
+ it('accepts a suffixed context window and stores the plain count', async () => {
|
|
|
+ const { mutate } = await mountSection({
|
|
|
+ mutate: vi.fn(() => Promise.resolve(ok(wireNamespaces()[0]))),
|
|
|
+ })
|
|
|
+ fireEvent.click(screen.getByText(en.customized))
|
|
|
+ expandRow(1)
|
|
|
+ expandRow(2)
|
|
|
+ const windows = capacityInputs(en.contextWindow)
|
|
|
+ // The inherited 1000000 reads back short.
|
|
|
+ expect((windows[0] as HTMLInputElement).value).toBe('1M')
|
|
|
+
|
|
|
+ // Keystrokes stay verbatim while the row has focus, so typing `1000` does
|
|
|
+ // not rewrite itself to `1K` mid-word.
|
|
|
+ fireEvent.change(windows[0] as HTMLInputElement, { target: { value: '1000' } })
|
|
|
+ expect((windows[0] as HTMLInputElement).value).toBe('1000')
|
|
|
+ fireEvent.change(windows[0] as HTMLInputElement, { target: { value: '1000K' } })
|
|
|
+ expect((windows[0] as HTMLInputElement).value).toBe('1000K')
|
|
|
+ // Blur settles the row to the canonical spelling of the same count.
|
|
|
+ fireEvent.blur(windows[0] as HTMLInputElement)
|
|
|
+ expect((windows[0] as HTMLInputElement).value).toBe('1M')
|
|
|
+
|
|
|
+ fireEvent.change(windows[1] as HTMLInputElement, { target: { value: '256K' } })
|
|
|
+ fireEvent.blur(windows[1] as HTMLInputElement)
|
|
|
+ fireEvent.click(screen.getByText(en.apply))
|
|
|
+
|
|
|
+ await waitFor(() => { expect(mutate).toHaveBeenCalledTimes(1) })
|
|
|
+ expect(mutate.mock.calls[0]?.[0]).toEqual({
|
|
|
+ ns: 'llm-deepseek',
|
|
|
+ ops: [{
|
|
|
+ op: 'set',
|
|
|
+ path: ['models'],
|
|
|
+ value: [
|
|
|
+ { ...DEFAULT_DEEPSEEK_MODELS[0], contextWindow: 1_000_000 },
|
|
|
+ { ...DEFAULT_DEEPSEEK_MODELS[1], contextWindow: 256_000 },
|
|
|
+ ],
|
|
|
+ }],
|
|
|
+ expectedRevision: 0,
|
|
|
+ })
|
|
|
+ })
|
|
|
+
|
|
|
+ it('keeps unreadable context-window text on screen and refuses the write', async () => {
|
|
|
+ const { mutate } = await mountSection()
|
|
|
+ fireEvent.click(screen.getByText(en.customized))
|
|
|
+ expandRow(1)
|
|
|
+ expandRow(2)
|
|
|
+ const windows = capacityInputs(en.contextWindow)
|
|
|
+ fireEvent.change(windows[0] as HTMLInputElement, { target: { value: '1 gazillion' } })
|
|
|
+ // Blurring a row that is not the edited one leaves the buffer alone.
|
|
|
+ fireEvent.blur(windows[1] as HTMLInputElement)
|
|
|
+ fireEvent.blur(windows[0] as HTMLInputElement)
|
|
|
+ // The text the user typed is still there to correct.
|
|
|
+ expect((windows[0] as HTMLInputElement).value).toBe('1 gazillion')
|
|
|
+
|
|
|
+ fireEvent.click(screen.getByText(en.apply))
|
|
|
+ await screen.findByText(`Model 1: ${en.modelContextInvalid}`)
|
|
|
+ expect(mutate).not.toHaveBeenCalled()
|
|
|
+ })
|
|
|
+
|
|
|
+ it.each([
|
|
|
+ ['the schema default', undefined],
|
|
|
+ ['the composition entry', { models: [{ id: 'pinned-by-deployment' }] }],
|
|
|
+ ])('restores %s the moment the override is dropped, not after a reload', async (_label, base) => {
|
|
|
+ // The regression: reset read the EFFECTIVE value, which still carries the
|
|
|
+ // stored override until the unset is applied — so the rows did not change
|
|
|
+ // and the catalog only looked restored after reopening the card.
|
|
|
+ const { face } = scriptedFace()
|
|
|
+ const stored = { models: [{ id: 'user-only-model', name: 'User Only' }] }
|
|
|
+ const overridden: SettingsNamespaceView = {
|
|
|
+ ns: 'llm-deepseek',
|
|
|
+ schema: JSON.parse(JSON.stringify(DeepSeekConfig.toJSON())) as unknown,
|
|
|
+ value: { ...stored, defaultContextWindow: 1_000_000 },
|
|
|
+ ...base === undefined ? {} : { base },
|
|
|
+ user: stored,
|
|
|
+ applies: 'live',
|
|
|
+ secrets: [],
|
|
|
+ revision: 0,
|
|
|
+ }
|
|
|
+ const { ProviderEditor } = await import('../src/client/ProviderEditor.tsx')
|
|
|
+ render(<ProviderEditor
|
|
|
+ provider="deepseek-official"
|
|
|
+ displayName="DeepSeek"
|
|
|
+ namespace={overridden}
|
|
|
+ settingsPath={[]}
|
|
|
+ api={face as never}
|
|
|
+ t={t}
|
|
|
+ readOnly={false}
|
|
|
+ onClose={() => {}}
|
|
|
+ />)
|
|
|
+ fireEvent.click(screen.getByText(en.customized))
|
|
|
+ expect(screen.getByText(en.modelsCustomized)).toBeTruthy()
|
|
|
+ expect(screen.getAllByLabelText(new RegExp(en.modelId)).map(input => (input as HTMLInputElement).value))
|
|
|
+ .toEqual(['user-only-model'])
|
|
|
+
|
|
|
+ fireEvent.click(screen.getByText(en.resetModels))
|
|
|
+
|
|
|
+ expect(screen.getByText(en.modelsInherited)).toBeTruthy()
|
|
|
+ expect(screen.getAllByLabelText(new RegExp(en.modelId)).map(input => (input as HTMLInputElement).value))
|
|
|
+ .toEqual(base === undefined ? ['deepseek-v4-flash', 'deepseek-v4-pro'] : ['pinned-by-deployment'])
|
|
|
+ })
|
|
|
+
|
|
|
+ it('keeps every row\'s unreadable text, not just the last one edited', async () => {
|
|
|
+ // The regression: one active buffer meant editing a second row displaced
|
|
|
+ // the first, which then fell back to rendering its stored NaN as `NaN` —
|
|
|
+ // losing the text the user was told they could still correct.
|
|
|
+ await mountSection()
|
|
|
+ fireEvent.click(screen.getByText(en.customized))
|
|
|
+ expandRow(1)
|
|
|
+ expandRow(2)
|
|
|
+ const windows = capacityInputs(en.contextWindow)
|
|
|
+ fireEvent.change(windows[0] as HTMLInputElement, { target: { value: 'not a number' } })
|
|
|
+ fireEvent.blur(windows[0] as HTMLInputElement)
|
|
|
+ fireEvent.change(windows[1] as HTMLInputElement, { target: { value: '2M' } })
|
|
|
+
|
|
|
+ expect((windows[0] as HTMLInputElement).value).toBe('not a number')
|
|
|
+ expect((windows[1] as HTMLInputElement).value).toBe('2M')
|
|
|
+ })
|
|
|
+
|
|
|
+ it('re-keys the typed text around a removed row', async () => {
|
|
|
+ await mountSection()
|
|
|
+ fireEvent.click(screen.getByText(en.customized))
|
|
|
+ const windows = (): HTMLInputElement[] => capacityInputs(en.contextWindow)
|
|
|
+ const removeRow = (at: number): void => {
|
|
|
+ fireEvent.click(screen.getAllByLabelText(new RegExp(en.removeModel))[at] as HTMLElement)
|
|
|
+ }
|
|
|
+ // Three rows, with text parked on the outer two.
|
|
|
+ fireEvent.click(screen.getByText(en.addModel))
|
|
|
+ expandRow(1)
|
|
|
+ expandRow(2)
|
|
|
+ expandRow(3)
|
|
|
+ fireEvent.change(windows()[0] as HTMLInputElement, { target: { value: 'top text' } })
|
|
|
+ fireEvent.blur(windows()[0] as HTMLInputElement)
|
|
|
+ fireEvent.change(windows()[2] as HTMLInputElement, { target: { value: 'bottom text' } })
|
|
|
+ fireEvent.blur(windows()[2] as HTMLInputElement)
|
|
|
+
|
|
|
+ // Dropping the middle row leaves the row above untouched and carries the
|
|
|
+ // row below down with its own text, rather than stranding it.
|
|
|
+ removeRow(1)
|
|
|
+ expect(windows()).toHaveLength(2)
|
|
|
+ expect((windows()[0] as HTMLInputElement).value).toBe('top text')
|
|
|
+ expect((windows()[1] as HTMLInputElement).value).toBe('bottom text')
|
|
|
+
|
|
|
+ // Dropping a row that holds text takes that text with it; the survivor
|
|
|
+ // keeps its own rather than inheriting the deleted row's.
|
|
|
+ removeRow(0)
|
|
|
+ expect(windows()).toHaveLength(1)
|
|
|
+ expect((windows()[0] as HTMLInputElement).value).toBe('bottom text')
|
|
|
+ })
|
|
|
+
|
|
|
+ it('drops the typed text when reset replaces the rows it annotated', async () => {
|
|
|
+ // The regression: reset removed the override but left the buffer, so an
|
|
|
+ // inherited row displayed text no settings layer stores — and because an
|
|
|
+ // unreadable buffer never settles, it stayed there indefinitely.
|
|
|
+ const { mutate } = await mountSection({
|
|
|
+ mutate: vi.fn(() => Promise.resolve(ok(wireNamespaces()[0]))),
|
|
|
+ })
|
|
|
+ fireEvent.click(screen.getByText(en.customized))
|
|
|
+ expandRow(1)
|
|
|
+ const windows = capacityInputs(en.contextWindow)
|
|
|
+ fireEvent.change(windows[0] as HTMLInputElement, { target: { value: 'garbage' } })
|
|
|
+ fireEvent.blur(windows[0] as HTMLInputElement)
|
|
|
+ fireEvent.click(screen.getByText(en.resetModels))
|
|
|
+
|
|
|
+ // Reset collapses every row, so the restored capacity needs opening again.
|
|
|
+ expandRow(1)
|
|
|
+ const restored = capacityInputs(en.contextWindow)
|
|
|
+ expect((restored[0] as HTMLInputElement).value).toBe('1M')
|
|
|
+
|
|
|
+ // Reset put the draft back where it started, so Apply writes nothing at
|
|
|
+ // all rather than persisting whatever the stale text had parsed to.
|
|
|
+ fireEvent.click(screen.getByText(en.apply))
|
|
|
+ await waitFor(() => { expect(screen.getByText(en.apply)).toBeTruthy() })
|
|
|
+ expect(mutate).not.toHaveBeenCalled()
|
|
|
+ })
|
|
|
+
|
|
|
+ it('edits an output cap per model and carries its text across a removal', async () => {
|
|
|
+ const { mutate } = await mountSection({
|
|
|
+ mutate: vi.fn(() => Promise.resolve(ok(wireNamespaces()[0]))),
|
|
|
+ })
|
|
|
+ fireEvent.click(screen.getByText(en.customized))
|
|
|
+ expandRow(1)
|
|
|
+ expandRow(2)
|
|
|
+ // The profile's own cap is the placeholder both rows inherit.
|
|
|
+ expect(capacityInputs(en.maxTokens).map(input => input.placeholder)).toEqual(['256K', '256K'])
|
|
|
+
|
|
|
+ fireEvent.change(screen.getByLabelText(`${en.maxTokens} 2`), { target: { value: '64K' } })
|
|
|
+ fireEvent.blur(screen.getByLabelText(`${en.maxTokens} 2`))
|
|
|
+ expect(screen.getByLabelText<HTMLInputElement>(`${en.maxTokens} 2`).value).toBe('64K')
|
|
|
+
|
|
|
+ // Dropping the row above carries the cap text down with its own row.
|
|
|
+ fireEvent.click(screen.getAllByLabelText(new RegExp(en.removeModel))[0] as HTMLElement)
|
|
|
+ expect(screen.getByLabelText<HTMLInputElement>(`${en.maxTokens} 1`).value).toBe('64K')
|
|
|
+ // The disclosure closes on a second press.
|
|
|
+ expandRow(1)
|
|
|
+ expect(screen.queryByLabelText(`${en.maxTokens} 1`)).toBeNull()
|
|
|
+
|
|
|
+ fireEvent.click(screen.getByText(en.apply))
|
|
|
+ await waitFor(() => { expect(mutate).toHaveBeenCalledTimes(1) })
|
|
|
+ expect(mutate.mock.calls[0]?.[0]).toEqual({
|
|
|
+ ns: 'llm-deepseek',
|
|
|
+ ops: [{
|
|
|
+ op: 'set',
|
|
|
+ path: ['models'],
|
|
|
+ value: [{ ...DEFAULT_DEEPSEEK_MODELS[1], maxTokens: 64_000 }],
|
|
|
+ }],
|
|
|
+ expectedRevision: 0,
|
|
|
+ })
|
|
|
+ })
|
|
|
+
|
|
|
+ it('settles a pasted id and refuses whitespace that would never match', async () => {
|
|
|
+ await mountSection()
|
|
|
+ fireEvent.click(screen.getByText(en.customized))
|
|
|
+ const ids = screen.getAllByLabelText<HTMLInputElement>(new RegExp(en.modelId))
|
|
|
+ fireEvent.change(ids[0] as HTMLInputElement, { target: { value: ' deepseek-v4-flash ' } })
|
|
|
+ fireEvent.blur(ids[0] as HTMLInputElement)
|
|
|
+ expect((ids[0] as HTMLInputElement).value).toBe('deepseek-v4-flash')
|
|
|
+ // A settled id needs no second trim.
|
|
|
+ fireEvent.blur(ids[0] as HTMLInputElement)
|
|
|
+ expect((ids[0] as HTMLInputElement).value).toBe('deepseek-v4-flash')
|
|
|
+
|
|
|
+ // An id that is only whitespace is as absent as an empty one, and a padded
|
|
|
+ // id no longer slips past the duplicate check against its own twin.
|
|
|
+ expect(validateDeepSeekModels([{ id: ' ' }])).toEqual({ index: 0, key: 'modelIdRequired' })
|
|
|
+ expect(validateDeepSeekModels([{ id: 'model' }, { id: 'model ' }]))
|
|
|
+ .toEqual({ index: 1, key: 'modelIdDuplicate' })
|
|
|
+ })
|
|
|
+
|
|
|
+ it('renders malformed draft fallbacks without inventing catalog values', () => {
|
|
|
+ render(<DeepSeekModelsEditor
|
|
|
+ models={[{}]}
|
|
|
+ overridden={false}
|
|
|
+ defaultContextWindow={undefined}
|
|
|
+ defaultMaxTokens={undefined}
|
|
|
+ t={t}
|
|
|
+ disabled={true}
|
|
|
+ onChange={vi.fn()}
|
|
|
+ onReset={vi.fn()}
|
|
|
+ />)
|
|
|
+ expect(screen.getByLabelText<HTMLInputElement>(`${en.modelId} 1`).value).toBe('')
|
|
|
+ expandRow(1)
|
|
|
+ expect(screen.getByLabelText<HTMLInputElement>(`${en.contextWindow} 1`).placeholder)
|
|
|
+ .toBe(en.contextWindowPlaceholder)
|
|
|
+ expect(screen.getByLabelText<HTMLInputElement>(`${en.maxTokens} 1`).placeholder)
|
|
|
+ .toBe(en.maxTokensPlaceholder)
|
|
|
+ })
|
|
|
+
|
|
|
+ it('can empty and reset the model override, then clear optional fields without dropping hidden data', async () => {
|
|
|
+ const { mutate } = await mountSection({
|
|
|
+ mutate: vi.fn(() => Promise.resolve(ok(wireNamespaces()[0]))),
|
|
|
+ })
|
|
|
+ fireEvent.click(screen.getByText(en.customized))
|
|
|
+ fireEvent.click(screen.getAllByLabelText(new RegExp(en.removeModel))[0] as HTMLElement)
|
|
|
+ fireEvent.click(screen.getByLabelText(new RegExp(en.removeModel)))
|
|
|
+ expect(screen.getByText(en.modelsEmpty)).toBeTruthy()
|
|
|
+ fireEvent.click(screen.getByText(en.resetModels))
|
|
|
+ expect(screen.getByText(en.modelsInherited)).toBeTruthy()
|
|
|
+
|
|
|
+ const names = screen.getAllByLabelText(new RegExp(en.modelName))
|
|
|
+ expandRow(1)
|
|
|
+ const windows = capacityInputs(en.contextWindow)
|
|
|
+ fireEvent.change(names[0] as HTMLInputElement, { target: { value: '' } })
|
|
|
+ fireEvent.change(windows[0] as HTMLInputElement, { target: { value: '' } })
|
|
|
+ fireEvent.click(screen.getByText(en.apply))
|
|
|
+
|
|
|
+ await waitFor(() => { expect(mutate).toHaveBeenCalledTimes(1) })
|
|
|
+ expect(mutate.mock.calls[0]?.[0]).toEqual({
|
|
|
+ ns: 'llm-deepseek',
|
|
|
+ ops: [{
|
|
|
+ op: 'set',
|
|
|
+ path: ['models'],
|
|
|
+ value: [
|
|
|
+ { id: 'deepseek-v4-flash', description: 'Preserved hidden detail' },
|
|
|
+ DEFAULT_DEEPSEEK_MODELS[1],
|
|
|
+ ],
|
|
|
+ }],
|
|
|
+ expectedRevision: 0,
|
|
|
+ })
|
|
|
+ })
|
|
|
+
|
|
|
it('clears an inherited override with an unset op, never a whole-section replace', async () => {
|
|
|
// The data-loss shape: the old path rebuilt the section from the REDACTED
|
|
|
// user layer and replaced it wholesale, deleting any stored literal key.
|