@linxin666/dsh-pet 0.3.25 → 0.4.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.i18n.yaml +2 -2
- package/README.md +16 -28
- package/README.zh.md +16 -28
- package/lib/client.js +74 -65
- package/lib/client.js.map +1 -1
- package/lib/index.js +85 -12
- package/lib/types/client/PetSettingsCard.d.ts +16 -12
- package/lib/types/client/PetSettingsCard.d.ts.map +1 -1
- package/lib/types/client/PetSettingsCard.js +77 -49
- package/lib/types/client/locales.d.ts +0 -2
- package/lib/types/client/locales.d.ts.map +1 -1
- package/lib/types/client/locales.js +0 -2
- package/lib/types/mount-once.d.ts +38 -7
- package/lib/types/mount-once.d.ts.map +1 -1
- package/lib/types/mount-once.js +96 -12
- package/package.json +1 -1
- package/src/client/PetSettingsCard.test.tsx +115 -4
- package/src/client/PetSettingsCard.tsx +76 -66
- package/src/client/locales.ts +0 -2
- package/src/client/pet-css.test.ts +8 -0
- package/src/client/settings-section.module.css +0 -23
- package/src/mount-once.ts +107 -13
package/lib/types/mount-once.js
CHANGED
|
@@ -5,38 +5,122 @@
|
|
|
5
5
|
* the loader accepts a standalone install of the same package side by side;
|
|
6
6
|
* without this guard the second instance would still re-register the same
|
|
7
7
|
* webserver routes, tools, settings namespaces, and system-prompt sections
|
|
8
|
-
* and fail the boot. mountOnce makes
|
|
9
|
-
*
|
|
10
|
-
* package name in the client module host).
|
|
8
|
+
* and fail the boot. mountOnce makes a mount of an already-mounted package a
|
|
9
|
+
* no-op for as long as the first instance lives (the browser half is already
|
|
10
|
+
* deduped by package name in the client module host).
|
|
11
|
+
*
|
|
12
|
+
* A no-op is only safe while the holder is ALIVE. The holder can be disposed
|
|
13
|
+
* long after the refused mount ran its course: the Host reloads a profile by
|
|
14
|
+
* creating the new loader entries before the old ones are torn down (a
|
|
15
|
+
* plugin-manager enable/disable/install write, a settings-driven row reload,
|
|
16
|
+
* HMR), so the new aggregate shell entry mounts its family plugin while the
|
|
17
|
+
* previous entry still owns the name. Dropping that refused mount lost the
|
|
18
|
+
* plugin for good - the previous entry then disposed its own mount, releasing
|
|
19
|
+
* the name with nobody left to take it, and the family row stayed listed as
|
|
20
|
+
* active while its host routes 404ed (the task-board panel showed
|
|
21
|
+
* "board.hostError.notMounted", no degraded record appeared, and only a Host
|
|
22
|
+
* restart recovered it).
|
|
23
|
+
*
|
|
24
|
+
* The refused mount is therefore QUEUED, not dropped, and replayed the moment
|
|
25
|
+
* the holder releases the name - if the waiting fiber is still alive then. The
|
|
26
|
+
* single-instance guarantee is unchanged: exactly one mount is live per
|
|
27
|
+
* package name, and the replay re-enters the guard so a later mount still
|
|
28
|
+
* dedupes against it.
|
|
11
29
|
*
|
|
12
30
|
* The registry rides a global symbol so two module instances of the same
|
|
13
|
-
* package (npm copy vs repository link) still share one verdict.
|
|
14
|
-
*
|
|
31
|
+
* package (npm copy vs repository link) still share one verdict. That symbol
|
|
32
|
+
* is a CROSS-REPOSITORY contract, not this file's private state: the four
|
|
33
|
+
* satellite packages (dsh-skins / dsh-pet / dsh-presets /
|
|
34
|
+
* dsh-community-plugins) are separate repositories carrying their own copy of
|
|
35
|
+
* this guard, rebuilt on their own schedule, so the value under `MOUNTED`
|
|
36
|
+
* must keep the shape every published copy reads (a `Set` of package names
|
|
37
|
+
* with `has`/`add`/`delete`). The wait queues this guard added therefore live
|
|
38
|
+
* under their own additive key, and the registry reads back a `Set` even when
|
|
39
|
+
* some other build left a different value there. Changing `MOUNTED`'s shape
|
|
40
|
+
* in place broke that contract once: a satellite's legacy copy created a
|
|
41
|
+
* `Set`, the family's new copy read it as a `Map`, and every family row
|
|
42
|
+
* mounted after it failed with "claims.get is not a function".
|
|
43
|
+
*
|
|
44
|
+
* cordis `ctx.effect` runs its callback immediately and treats the callback's
|
|
15
45
|
* return value as the fiber disposer, so the unmarker is returned, not run.
|
|
16
46
|
*/
|
|
47
|
+
/** Published cross-repository contract: package names currently mounted. */
|
|
17
48
|
const MOUNTED = Symbol.for('dsh-web.mounted-plugins');
|
|
49
|
+
/** Additive key this guard owns: refused mounts waiting for the name. */
|
|
50
|
+
const WAITERS = Symbol.for('dsh-web.mounted-plugins.waiters');
|
|
51
|
+
/**
|
|
52
|
+
* The shared name registry, always a `Set` whatever another build stored here:
|
|
53
|
+
* a foreign value (an interim shape, a hand-written global) must not take every
|
|
54
|
+
* family plugin down with it.
|
|
55
|
+
* @returns the process-wide set of mounted package names.
|
|
56
|
+
*/
|
|
18
57
|
function mountedSet() {
|
|
19
58
|
const registry = globalThis;
|
|
20
|
-
|
|
59
|
+
const existing = registry[MOUNTED];
|
|
60
|
+
if (existing instanceof Set)
|
|
61
|
+
return existing;
|
|
62
|
+
const created = new Set();
|
|
63
|
+
registry[MOUNTED] = created;
|
|
64
|
+
return created;
|
|
65
|
+
}
|
|
66
|
+
/** Queue per package name for mounts refused while a holder was alive. */
|
|
67
|
+
function mountWaiters() {
|
|
68
|
+
const registry = globalThis;
|
|
69
|
+
return (registry[WAITERS] ??= new Map());
|
|
21
70
|
}
|
|
22
71
|
/**
|
|
23
72
|
* Wrap a cordis plugin apply so the package runs at most once per process.
|
|
24
|
-
* The first mount registers normally and
|
|
25
|
-
*
|
|
73
|
+
* The first mount registers normally and releases the name when its fiber
|
|
74
|
+
* disposes; a mount refused while that name is held waits for the release and
|
|
75
|
+
* then runs, unless its own fiber disposes first.
|
|
26
76
|
* @param packageName - npm package identity shared by every install source.
|
|
27
77
|
* @param fn - the original plugin apply.
|
|
28
78
|
* @returns an apply of the same shape.
|
|
29
79
|
*/
|
|
30
80
|
export function mountOnce(packageName, fn) {
|
|
31
|
-
|
|
81
|
+
const mount = (...args) => {
|
|
32
82
|
const mounted = mountedSet();
|
|
33
|
-
|
|
83
|
+
const ctx = args[0];
|
|
84
|
+
if (mounted.has(packageName)) {
|
|
85
|
+
// Another fiber (the aggregate row, a standalone install, or a copy built
|
|
86
|
+
// from an older revision of this guard) owns the name. Queue instead of
|
|
87
|
+
// dropping: dropping lost the plugin for the rest of the process when the
|
|
88
|
+
// holder was disposed by a reload.
|
|
89
|
+
const waiters = mountWaiters();
|
|
90
|
+
const queue = waiters.get(packageName) ?? [];
|
|
91
|
+
let alive = true;
|
|
92
|
+
const pending = {
|
|
93
|
+
run: () => {
|
|
94
|
+
if (alive)
|
|
95
|
+
mount(...args);
|
|
96
|
+
},
|
|
97
|
+
};
|
|
98
|
+
// A fiber that dies while it waits must not be replayed afterwards: its
|
|
99
|
+
// context is disposed and registering effects on it would throw.
|
|
100
|
+
ctx?.effect?.(() => () => {
|
|
101
|
+
alive = false;
|
|
102
|
+
const index = queue.indexOf(pending);
|
|
103
|
+
if (index >= 0)
|
|
104
|
+
queue.splice(index, 1);
|
|
105
|
+
});
|
|
106
|
+
queue.push(pending);
|
|
107
|
+
waiters.set(packageName, queue);
|
|
34
108
|
return;
|
|
109
|
+
}
|
|
35
110
|
mounted.add(packageName);
|
|
36
|
-
const ctx = args[0];
|
|
37
111
|
ctx?.effect?.(() => () => {
|
|
38
112
|
mounted.delete(packageName);
|
|
113
|
+
const waiters = mountWaiters();
|
|
114
|
+
const queue = waiters.get(packageName);
|
|
115
|
+
if (queue === undefined)
|
|
116
|
+
return;
|
|
117
|
+
waiters.delete(packageName);
|
|
118
|
+
// Defer the handover one microtask so the disposing mount's own effects
|
|
119
|
+
// (routes, ledger, timers) are torn down before the next one registers.
|
|
120
|
+
for (const waiter of queue.splice(0))
|
|
121
|
+
queueMicrotask(() => { waiter.run(); });
|
|
39
122
|
});
|
|
40
123
|
return fn(...args);
|
|
41
|
-
}
|
|
124
|
+
};
|
|
125
|
+
return mount;
|
|
42
126
|
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@linxin666/dsh-pet",
|
|
3
3
|
"description": "Multi-pet companion plugin for the dsh web GUI: a registry-driven floating pet that reacts to model activity, with per-pet naming, petting/feeding interactions and an affinity score",
|
|
4
|
-
"version": "0.3
|
|
4
|
+
"version": "0.4.3",
|
|
5
5
|
"publishConfig": {
|
|
6
6
|
"access": "public"
|
|
7
7
|
},
|
|
@@ -1,7 +1,16 @@
|
|
|
1
1
|
// @vitest-environment jsdom
|
|
2
|
-
|
|
2
|
+
/**
|
|
3
|
+
* The pet settings card under the aggregate shell, which serves no Host
|
|
4
|
+
* settings form for the 'pet' namespace: the pet choice and its visibility
|
|
5
|
+
* switch both write through the pet API, so the pet can still be turned off
|
|
6
|
+
* when the Host form is absent.
|
|
7
|
+
*/
|
|
8
|
+
import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest'
|
|
9
|
+
import { cleanup, render, screen } from '@testing-library/react'
|
|
10
|
+
import { useSyncExternalStore } from 'react'
|
|
3
11
|
import type { ConfigForm } from '@deepseek-ai/dsh-client-ui-settings/client'
|
|
4
|
-
import { PetSettingsCardController, type PetSettings } from './PetSettingsCard.tsx'
|
|
12
|
+
import { PetSettingsCard, PetSettingsCardController, type PetSettings } from './PetSettingsCard.tsx'
|
|
13
|
+
import { t } from './locales.ts'
|
|
5
14
|
|
|
6
15
|
vi.mock('@deepseek-ai/dsh-client-store', () => ({ // test-standards-allow: the SDK client bundle is a browser module factory and cannot be imported by Vitest
|
|
7
16
|
createSnapshotStore: (initial: unknown) => {
|
|
@@ -32,7 +41,12 @@ function response(value: unknown, ok = true): Response {
|
|
|
32
41
|
return { ok, status: ok ? 200 : 500, json: async () => value } as Response
|
|
33
42
|
}
|
|
34
43
|
|
|
44
|
+
beforeAll(() => {
|
|
45
|
+
document.documentElement.lang = 'zh'
|
|
46
|
+
})
|
|
47
|
+
|
|
35
48
|
afterEach(() => {
|
|
49
|
+
cleanup()
|
|
36
50
|
vi.useRealTimers()
|
|
37
51
|
vi.unstubAllGlobals()
|
|
38
52
|
})
|
|
@@ -46,7 +60,6 @@ describe('pet selection without a Host settings form', () => {
|
|
|
46
60
|
const fetch = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
|
|
47
61
|
const path = String(input)
|
|
48
62
|
if (path === '/api/pet/pets') return response([{ id: 'whale-girl', displayName: '鲸鱼娘' }, { id: 'jyn', displayName: '女仆鲸鱼娘' }])
|
|
49
|
-
if (path === '/api/pet/diagnostics') return response({ diagnostics: [] })
|
|
50
63
|
if (path === '/api/pet/state') return response({ pet: { id: selected } })
|
|
51
64
|
if (path === '/api/pet/set-pet') {
|
|
52
65
|
selected = JSON.parse(String(init?.body)).petId as string
|
|
@@ -84,7 +97,6 @@ describe('pet selection without a Host settings form', () => {
|
|
|
84
97
|
vi.stubGlobal('fetch', vi.fn(async (input: RequestInfo | URL) => {
|
|
85
98
|
const path = String(input)
|
|
86
99
|
if (path === '/api/pet/pets') return response([{ id: 'whale-girl', displayName: '鲸鱼娘' }, { id: 'jyn', displayName: '女仆鲸鱼娘' }])
|
|
87
|
-
if (path === '/api/pet/diagnostics') return response({ diagnostics: [] })
|
|
88
100
|
if (path === '/api/pet/state') return response({ pet: { id: 'whale-girl' } })
|
|
89
101
|
if (path === '/api/pet/set-pet') return response({ ok: false, error: 'unknown-pet' })
|
|
90
102
|
throw new Error('unexpected request: ' + path)
|
|
@@ -101,3 +113,102 @@ describe('pet selection without a Host settings form', () => {
|
|
|
101
113
|
controller.dispose()
|
|
102
114
|
})
|
|
103
115
|
})
|
|
116
|
+
|
|
117
|
+
describe('pet visibility without a Host settings form', () => {
|
|
118
|
+
it('user turns the pet off through its persisted API and confirms the read-back', async () => {
|
|
119
|
+
// Given the aggregate shell exposes no pet configuration form and the pet is showing
|
|
120
|
+
vi.useFakeTimers()
|
|
121
|
+
const { scope, mutate } = unavailableForm()
|
|
122
|
+
let visible = true
|
|
123
|
+
const fetch = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
|
|
124
|
+
const path = String(input)
|
|
125
|
+
if (path === '/api/pet/pets') return response([{ id: 'whale-girl', displayName: '鲸鱼娘' }])
|
|
126
|
+
if (path === '/api/pet/state') return response({ pet: { id: 'whale-girl' }, display: { visible } })
|
|
127
|
+
if (path === '/api/pet/set-visible') {
|
|
128
|
+
visible = JSON.parse(String(init?.body)).visible as boolean
|
|
129
|
+
return response({ ok: true, display: { visible } })
|
|
130
|
+
}
|
|
131
|
+
throw new Error('unexpected request: ' + path)
|
|
132
|
+
})
|
|
133
|
+
vi.stubGlobal('fetch', fetch)
|
|
134
|
+
const controller = new PetSettingsCardController(scope)
|
|
135
|
+
await vi.advanceTimersByTimeAsync(0)
|
|
136
|
+
const face = controller.inject()
|
|
137
|
+
const state = () => face.hooks.petSettingsCard.getSnapshot()
|
|
138
|
+
|
|
139
|
+
// When the user switches the pet off and clicks Save
|
|
140
|
+
expect(state()).toMatchObject({ petSelectionFallback: true, visible: { text: 'true' }, dirty: false })
|
|
141
|
+
face.edit('visible', 'false')
|
|
142
|
+
expect(state()).toMatchObject({ dirty: true, visible: { text: 'false' } })
|
|
143
|
+
face.save()
|
|
144
|
+
await vi.waitFor(() => { expect(state().saving).toBe(false) })
|
|
145
|
+
|
|
146
|
+
// Then the pet service holds it, the draft clears, and the absent Host form is untouched
|
|
147
|
+
expect(visible).toBe(false)
|
|
148
|
+
expect(state()).toMatchObject({ dirty: false, failed: false, visible: { text: 'false' } })
|
|
149
|
+
expect(mutate).not.toHaveBeenCalled()
|
|
150
|
+
expect(fetch).toHaveBeenCalledWith('/api/pet/set-visible', expect.objectContaining({ body: JSON.stringify({ visible: false }) }))
|
|
151
|
+
controller.dispose()
|
|
152
|
+
})
|
|
153
|
+
|
|
154
|
+
it('user keeps the draft when the pet API refuses the visibility write', async () => {
|
|
155
|
+
// Given the Host has no pet form and the pet API refuses visibility writes
|
|
156
|
+
vi.useFakeTimers()
|
|
157
|
+
const { scope } = unavailableForm()
|
|
158
|
+
vi.stubGlobal('fetch', vi.fn(async (input: RequestInfo | URL) => {
|
|
159
|
+
const path = String(input)
|
|
160
|
+
if (path === '/api/pet/pets') return response([{ id: 'whale-girl', displayName: '鲸鱼娘' }])
|
|
161
|
+
if (path === '/api/pet/state') return response({ pet: { id: 'whale-girl' }, display: { visible: true } })
|
|
162
|
+
if (path === '/api/pet/set-visible') return response({ ok: false }, false)
|
|
163
|
+
throw new Error('unexpected request: ' + path)
|
|
164
|
+
}))
|
|
165
|
+
const controller = new PetSettingsCardController(scope)
|
|
166
|
+
await vi.advanceTimersByTimeAsync(0)
|
|
167
|
+
const face = controller.inject()
|
|
168
|
+
// When the user saves a visibility the pet API refuses
|
|
169
|
+
face.edit('visible', 'false')
|
|
170
|
+
face.save()
|
|
171
|
+
await vi.waitFor(() => { expect(face.hooks.petSettingsCard.getSnapshot().saving).toBe(false) })
|
|
172
|
+
// Then the switch keeps the draft and the failure is visible
|
|
173
|
+
expect(face.hooks.petSettingsCard.getSnapshot()).toMatchObject({ dirty: true, failed: true, visible: { text: 'false' } })
|
|
174
|
+
controller.dispose()
|
|
175
|
+
})
|
|
176
|
+
})
|
|
177
|
+
|
|
178
|
+
describe('pet settings card rendered without a Host settings form', () => {
|
|
179
|
+
it('shows the visibility switch beside the pet choice and hides the Host-only fields', async () => {
|
|
180
|
+
// Given the aggregate shell exposes no pet configuration form
|
|
181
|
+
vi.useFakeTimers()
|
|
182
|
+
const { scope } = unavailableForm()
|
|
183
|
+
vi.stubGlobal('fetch', vi.fn(async (input: RequestInfo | URL) => {
|
|
184
|
+
const path = String(input)
|
|
185
|
+
if (path === '/api/pet/pets') return response([{ id: 'whale-girl', displayName: '鲸鱼娘' }])
|
|
186
|
+
if (path === '/api/pet/state') return response({ pet: { id: 'whale-girl' }, display: { visible: true } })
|
|
187
|
+
throw new Error('unexpected request: ' + path)
|
|
188
|
+
}))
|
|
189
|
+
const controller = new PetSettingsCardController(scope)
|
|
190
|
+
await vi.advanceTimersByTimeAsync(0)
|
|
191
|
+
const face = controller.inject()
|
|
192
|
+
const store = face.hooks.petSettingsCard
|
|
193
|
+
|
|
194
|
+
// When the card renders
|
|
195
|
+
render(
|
|
196
|
+
<PetSettingsCard
|
|
197
|
+
t={t}
|
|
198
|
+
usePetSettingsCard={select => useSyncExternalStore(store.subscribe, () => select(store.getSnapshot()))}
|
|
199
|
+
save={face.save}
|
|
200
|
+
discard={face.discard}
|
|
201
|
+
edit={face.edit}
|
|
202
|
+
resetField={face.resetField}
|
|
203
|
+
/>,
|
|
204
|
+
)
|
|
205
|
+
|
|
206
|
+
// Then the pet choice and the switch that turns the pet off are both on screen
|
|
207
|
+
expect(screen.getByLabelText(t('settings.pet'))).toBeTruthy()
|
|
208
|
+
expect(screen.getByLabelText(t('settings.visible'))).toBeTruthy()
|
|
209
|
+
// ...while the fields only the absent Host form owns stay hidden
|
|
210
|
+
expect(screen.queryByLabelText(t('settings.enabled'))).toBeNull()
|
|
211
|
+
expect(screen.queryByLabelText(t('settings.size'))).toBeNull()
|
|
212
|
+
controller.dispose()
|
|
213
|
+
})
|
|
214
|
+
})
|
|
@@ -61,8 +61,6 @@ export interface PetSettingsCardState extends CardShell {
|
|
|
61
61
|
decorationEnabled: CardFieldState
|
|
62
62
|
/** Pet choices (registry ids + display names), loaded from the host. */
|
|
63
63
|
petChoices: readonly { value: string; label: string }[]
|
|
64
|
-
/** Registry diagnostics (v1 migration hints, invalid entries), host-served. */
|
|
65
|
-
petDiagnostics: readonly PetDiagnosticView[]
|
|
66
64
|
}
|
|
67
65
|
|
|
68
66
|
/** The registration-side face the card's slot entry injects. */
|
|
@@ -79,12 +77,6 @@ interface PetChoice {
|
|
|
79
77
|
displayName: string
|
|
80
78
|
}
|
|
81
79
|
|
|
82
|
-
/** One registry diagnostic as served by '/api/pet/diagnostics' (#623). */
|
|
83
|
-
export interface PetDiagnosticView {
|
|
84
|
-
level: 'error' | 'warning'
|
|
85
|
-
message: string
|
|
86
|
-
}
|
|
87
|
-
|
|
88
80
|
/** Fetch the registry list (the same data the sprite renders from). */
|
|
89
81
|
async function fetchPetChoices(): Promise<PetChoice[]> {
|
|
90
82
|
const response = await fetch('/api/pet/pets')
|
|
@@ -92,21 +84,22 @@ async function fetchPetChoices(): Promise<PetChoice[]> {
|
|
|
92
84
|
return (await response.json()) as PetChoice[]
|
|
93
85
|
}
|
|
94
86
|
|
|
95
|
-
/**
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
87
|
+
/** The persisted state the fallback mirrors: the selection and its visibility. */
|
|
88
|
+
interface PetState {
|
|
89
|
+
/** Selected registry id. */
|
|
90
|
+
petId: string
|
|
91
|
+
/** Whether the pet is currently rendered. */
|
|
92
|
+
visible: boolean
|
|
101
93
|
}
|
|
102
94
|
|
|
103
|
-
/** Read the selection from the same persisted state the pet renders. */
|
|
104
|
-
async function
|
|
95
|
+
/** Read the selection and visibility from the same persisted state the pet renders. */
|
|
96
|
+
async function fetchPetState(): Promise<PetState> {
|
|
105
97
|
const response = await fetch('/api/pet/state')
|
|
106
98
|
if (!response.ok) throw new Error('pet state failed: ' + response.status)
|
|
107
|
-
const body = (await response.json()) as { pet?: { id?: unknown } }
|
|
99
|
+
const body = (await response.json()) as { pet?: { id?: unknown }; display?: { visible?: unknown } }
|
|
108
100
|
if (typeof body.pet?.id !== 'string') throw new Error('pet state has no selected pet')
|
|
109
|
-
|
|
101
|
+
// A Host without the display block predates the visibility switch; the pet shows.
|
|
102
|
+
return { petId: body.pet.id, visible: body.display?.visible !== false }
|
|
110
103
|
}
|
|
111
104
|
|
|
112
105
|
/** Bridges the 'pet' scope onto the card's staged form. */
|
|
@@ -118,9 +111,10 @@ export class PetSettingsCardController {
|
|
|
118
111
|
// without rebuilding the form.
|
|
119
112
|
private readonly petChoices: string[] = []
|
|
120
113
|
private readonly petLabels = new Map<string, string>()
|
|
121
|
-
private diagnostics: PetDiagnosticView[] = []
|
|
122
114
|
private selectedPetId: string | undefined
|
|
115
|
+
private selectedVisible: boolean | undefined
|
|
123
116
|
private stagedPetId: string | undefined
|
|
117
|
+
private stagedVisible: boolean | undefined
|
|
124
118
|
private savingPet = false
|
|
125
119
|
private petSaveFailed = false
|
|
126
120
|
private loaded = false
|
|
@@ -150,22 +144,10 @@ export class PetSettingsCardController {
|
|
|
150
144
|
this.pendingTimer = undefined
|
|
151
145
|
if (this.disposed) return
|
|
152
146
|
void this.loadPets()
|
|
153
|
-
void this.
|
|
154
|
-
void this.loadSelectedPet()
|
|
147
|
+
void this.loadPetState()
|
|
155
148
|
}, 0)
|
|
156
149
|
}
|
|
157
150
|
|
|
158
|
-
/** Fetch registry diagnostics once (soft-fail: an empty list on error). */
|
|
159
|
-
private async loadDiagnostics(): Promise<void> {
|
|
160
|
-
try {
|
|
161
|
-
this.diagnostics = await fetchPetDiagnostics()
|
|
162
|
-
if (this.disposed) return
|
|
163
|
-
this.store.set(this.projection())
|
|
164
|
-
} catch {
|
|
165
|
-
this.diagnostics = []
|
|
166
|
-
}
|
|
167
|
-
}
|
|
168
|
-
|
|
169
151
|
/** Resolve the registry choices once (retried a few times on failure). */
|
|
170
152
|
private async loadPets(): Promise<void> {
|
|
171
153
|
if (this.loaded || this.disposed) return
|
|
@@ -189,11 +171,12 @@ export class PetSettingsCardController {
|
|
|
189
171
|
}
|
|
190
172
|
}
|
|
191
173
|
|
|
192
|
-
private async
|
|
174
|
+
private async loadPetState(): Promise<void> {
|
|
193
175
|
try {
|
|
194
|
-
const
|
|
176
|
+
const state = await fetchPetState()
|
|
195
177
|
if (this.disposed) return
|
|
196
|
-
this.selectedPetId = petId
|
|
178
|
+
this.selectedPetId = state.petId
|
|
179
|
+
this.selectedVisible = state.visible
|
|
197
180
|
this.store.set(this.projection())
|
|
198
181
|
} catch {
|
|
199
182
|
// The Host form remains the authority when it is available. An absent
|
|
@@ -206,24 +189,58 @@ export class PetSettingsCardController {
|
|
|
206
189
|
return shell.available && !shell.exposed && this.selectedPetId !== undefined
|
|
207
190
|
}
|
|
208
191
|
|
|
209
|
-
|
|
192
|
+
/**
|
|
193
|
+
* The visibility the fallback switch renders: the staged draft when the user
|
|
194
|
+
* moved it, the persisted value otherwise. There is no user layer to override
|
|
195
|
+
* here, so the field is never marked overridden.
|
|
196
|
+
*/
|
|
197
|
+
private fallbackVisible(): CardFieldState {
|
|
198
|
+
const value = this.stagedVisible ?? this.selectedVisible
|
|
199
|
+
return { text: value === undefined ? '' : String(value), overridden: false, invalid: false }
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Persist the staged selection and visibility through the pet API — the only
|
|
204
|
+
* writer available when the aggregate shell serves this card, since there is
|
|
205
|
+
* no Host settings form to mutate — then confirm the read-back before the
|
|
206
|
+
* drafts are cleared.
|
|
207
|
+
*/
|
|
208
|
+
private async saveFallback(): Promise<void> {
|
|
210
209
|
const petId = this.stagedPetId
|
|
211
|
-
|
|
210
|
+
const visible = this.stagedVisible
|
|
211
|
+
if (this.savingPet) return
|
|
212
|
+
if (petId === undefined && visible === undefined) return
|
|
213
|
+
if (petId !== undefined && !this.petChoices.includes(petId)) return
|
|
212
214
|
this.savingPet = true
|
|
213
215
|
this.petSaveFailed = false
|
|
214
216
|
this.store.set(this.projection())
|
|
215
217
|
try {
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
218
|
+
if (petId !== undefined) {
|
|
219
|
+
const response = await fetch('/api/pet/set-pet', {
|
|
220
|
+
method: 'POST',
|
|
221
|
+
headers: { 'content-type': 'application/json' },
|
|
222
|
+
body: JSON.stringify({ petId }),
|
|
223
|
+
})
|
|
224
|
+
const result = (await response.json()) as { ok?: boolean; petId?: string }
|
|
225
|
+
if (!response.ok || result.ok !== true || result.petId !== petId) {
|
|
226
|
+
throw new Error('pet selection was not persisted')
|
|
227
|
+
}
|
|
224
228
|
}
|
|
225
|
-
|
|
229
|
+
if (visible !== undefined) {
|
|
230
|
+
const response = await fetch('/api/pet/set-visible', {
|
|
231
|
+
method: 'POST',
|
|
232
|
+
headers: { 'content-type': 'application/json' },
|
|
233
|
+
body: JSON.stringify({ visible }),
|
|
234
|
+
})
|
|
235
|
+
if (!response.ok) throw new Error('pet visibility was not persisted')
|
|
236
|
+
}
|
|
237
|
+
const persisted = await fetchPetState()
|
|
238
|
+
if (petId !== undefined && persisted.petId !== petId) throw new Error('pet selection was not persisted')
|
|
239
|
+
if (visible !== undefined && persisted.visible !== visible) throw new Error('pet visibility was not persisted')
|
|
240
|
+
this.selectedPetId = persisted.petId
|
|
241
|
+
this.selectedVisible = persisted.visible
|
|
226
242
|
if (this.stagedPetId === petId) this.stagedPetId = undefined
|
|
243
|
+
if (this.stagedVisible === visible) this.stagedVisible = undefined
|
|
227
244
|
} catch {
|
|
228
245
|
this.petSaveFailed = true
|
|
229
246
|
} finally {
|
|
@@ -241,7 +258,8 @@ export class PetSettingsCardController {
|
|
|
241
258
|
...(fallback ? {
|
|
242
259
|
exposed: true,
|
|
243
260
|
writable: true,
|
|
244
|
-
dirty: this.stagedPetId !== undefined && this.stagedPetId !== this.selectedPetId
|
|
261
|
+
dirty: this.stagedPetId !== undefined && this.stagedPetId !== this.selectedPetId
|
|
262
|
+
|| this.stagedVisible !== undefined && this.stagedVisible !== this.selectedVisible,
|
|
245
263
|
invalid: this.stagedPetId !== undefined && !this.petChoices.includes(this.stagedPetId),
|
|
246
264
|
saving: this.savingPet,
|
|
247
265
|
failed: this.petSaveFailed,
|
|
@@ -250,7 +268,7 @@ export class PetSettingsCardController {
|
|
|
250
268
|
petSelectionFallback: fallback,
|
|
251
269
|
enabled: this.form.field('enabled'),
|
|
252
270
|
decorationEnabled: this.form.field('decorationEnabled'),
|
|
253
|
-
visible: this.form.field('visible'),
|
|
271
|
+
visible: fallback ? this.fallbackVisible() : this.form.field('visible'),
|
|
254
272
|
size: this.form.field('size'),
|
|
255
273
|
right: this.form.field('right'),
|
|
256
274
|
bottom: this.form.field('bottom'),
|
|
@@ -261,7 +279,6 @@ export class PetSettingsCardController {
|
|
|
261
279
|
? { ...configuredPet, text: this.selectedPetId }
|
|
262
280
|
: configuredPet,
|
|
263
281
|
petChoices: this.petChoices.map(id => ({ value: id, label: this.petLabels.get(id) ?? id })),
|
|
264
|
-
petDiagnostics: this.diagnostics,
|
|
265
282
|
}
|
|
266
283
|
}
|
|
267
284
|
|
|
@@ -275,22 +292,25 @@ export class PetSettingsCardController {
|
|
|
275
292
|
hooks: { petSettingsCard: this.store },
|
|
276
293
|
edit: (field, value) => {
|
|
277
294
|
if (!this.fallback()) return actions.edit(field, value)
|
|
278
|
-
if (field
|
|
279
|
-
this.
|
|
295
|
+
if (field === 'petId') this.stagedPetId = value === '' ? undefined : value
|
|
296
|
+
else if (field === 'visible') this.stagedVisible = value === '' ? undefined : value === 'true'
|
|
297
|
+
else return
|
|
280
298
|
this.petSaveFailed = false
|
|
281
299
|
this.store.set(this.projection())
|
|
282
300
|
},
|
|
283
301
|
resetField: (field) => {
|
|
284
302
|
if (!this.fallback()) return actions.resetField(field)
|
|
285
|
-
if (field
|
|
286
|
-
this.
|
|
303
|
+
if (field === 'petId') this.stagedPetId = undefined
|
|
304
|
+
else if (field === 'visible') this.stagedVisible = undefined
|
|
305
|
+
else return
|
|
287
306
|
this.petSaveFailed = false
|
|
288
307
|
this.store.set(this.projection())
|
|
289
308
|
},
|
|
290
|
-
save: () => { if (this.fallback()) void this.
|
|
309
|
+
save: () => { if (this.fallback()) void this.saveFallback(); else actions.save() },
|
|
291
310
|
discard: () => {
|
|
292
311
|
if (!this.fallback()) return actions.discard()
|
|
293
312
|
this.stagedPetId = undefined
|
|
313
|
+
this.stagedVisible = undefined
|
|
294
314
|
this.petSaveFailed = false
|
|
295
315
|
this.store.set(this.projection())
|
|
296
316
|
},
|
|
@@ -378,17 +398,7 @@ export function PetSettingsCard(props: PetSettingsCardProps) {
|
|
|
378
398
|
onEdit={(text) => { props.edit('petId', text) }}
|
|
379
399
|
onReset={() => { props.resetField('petId') }}
|
|
380
400
|
/>
|
|
381
|
-
|
|
382
|
-
<li className={sectionCss.diagnostics} data-dsh-part="diagnostics">
|
|
383
|
-
<span className={sectionCss.diagnosticsTitle}>{t('settings.diagnosticsTitle')}</span>
|
|
384
|
-
<ul>
|
|
385
|
-
{state.petDiagnostics.map((diagnostic, index) => (
|
|
386
|
-
<li key={index} data-level={diagnostic.level}>{diagnostic.message}</li>
|
|
387
|
-
))}
|
|
388
|
-
</ul>
|
|
389
|
-
</li>
|
|
390
|
-
)}
|
|
391
|
-
{state.petSelectionFallback ? null : <BooleanField
|
|
401
|
+
<BooleanField
|
|
392
402
|
id="settings-pet-visible"
|
|
393
403
|
label={t('settings.visible')}
|
|
394
404
|
hint={t('settings.visibleHint')}
|
|
@@ -399,7 +409,7 @@ export function PetSettingsCard(props: PetSettingsCardProps) {
|
|
|
399
409
|
{...state.visible}
|
|
400
410
|
onEdit={(text) => { props.edit('visible', text) }}
|
|
401
411
|
onReset={() => { props.resetField('visible') }}
|
|
402
|
-
/>
|
|
412
|
+
/>
|
|
403
413
|
{state.petSelectionFallback ? null : <ValueField
|
|
404
414
|
id="settings-pet-size"
|
|
405
415
|
label={t('settings.size')}
|
package/src/client/locales.ts
CHANGED
|
@@ -57,7 +57,6 @@ export const zh = {
|
|
|
57
57
|
'pet.collapseSessions': '收起会话气泡',
|
|
58
58
|
// 一级设置页(settings.section 席位)。
|
|
59
59
|
'settings.title': '宠物',
|
|
60
|
-
'settings.diagnosticsTitle': '宠物目录诊断',
|
|
61
60
|
'settings.description': '选择宠物并调整它的显示布局。',
|
|
62
61
|
'settings.pet': '宠物',
|
|
63
62
|
'settings.petHint': '选择显示哪只宠物;每只宠物独立命名,可在宠物悬浮面板改名。',
|
|
@@ -143,7 +142,6 @@ export const en = {
|
|
|
143
142
|
'pet.collapseSessions': 'Collapse session bubbles',
|
|
144
143
|
// First-level settings section (the `settings.section` seat).
|
|
145
144
|
'settings.title': 'Pet',
|
|
146
|
-
'settings.diagnosticsTitle': 'Pet directory diagnostics',
|
|
147
145
|
'settings.description': 'Pick a pet and tune its display layout.',
|
|
148
146
|
'settings.pet': 'Pet',
|
|
149
147
|
'settings.petHint': 'Choose which pet shows. Names are stored per pet; rename from the pet hover panel.',
|
|
@@ -2,6 +2,14 @@ import { readFileSync } from 'node:fs'
|
|
|
2
2
|
import { describe, expect, it } from 'vitest'
|
|
3
3
|
|
|
4
4
|
const css = readFileSync(new URL('./pet.module.css', import.meta.url), 'utf8')
|
|
5
|
+
const settingsCss = readFileSync(new URL('./settings-section.module.css', import.meta.url), 'utf8')
|
|
6
|
+
|
|
7
|
+
describe('pet settings section css', () => {
|
|
8
|
+
it('carries no leftover registry diagnostics block after its removal', () => {
|
|
9
|
+
expect(settingsCss).not.toContain('.diagnostics')
|
|
10
|
+
expect(settingsCss).toContain('.sectionList')
|
|
11
|
+
})
|
|
12
|
+
})
|
|
5
13
|
|
|
6
14
|
describe('pet bubble typography (#1549)', () => {
|
|
7
15
|
it('scales the bubble text and padding from the sprite-supplied variable', () => {
|
|
@@ -3,26 +3,3 @@
|
|
|
3
3
|
margin: 0;
|
|
4
4
|
padding: 0;
|
|
5
5
|
}
|
|
6
|
-
|
|
7
|
-
/* Registry diagnostics block (pet-center M2 P7, issue #623). */
|
|
8
|
-
.diagnostics {
|
|
9
|
-
margin: 8px 0;
|
|
10
|
-
padding: 8px 12px;
|
|
11
|
-
border: 1px dashed var(--dsw-alias-border-l2);
|
|
12
|
-
border-radius: 8px;
|
|
13
|
-
font-size: 12px;
|
|
14
|
-
color: var(--dsw-alias-label-tertiary);
|
|
15
|
-
}
|
|
16
|
-
.diagnosticsTitle {
|
|
17
|
-
display: block;
|
|
18
|
-
font-weight: 600;
|
|
19
|
-
margin-bottom: 4px;
|
|
20
|
-
color: var(--dsw-alias-label-primary);
|
|
21
|
-
}
|
|
22
|
-
.diagnostics ul {
|
|
23
|
-
margin: 0;
|
|
24
|
-
padding-left: 16px;
|
|
25
|
-
}
|
|
26
|
-
.diagnostics li[data-level="error"] {
|
|
27
|
-
color: var(--dsw-alias-label-error, #c04848);
|
|
28
|
-
}
|