Snippet naming: content-derived on publish, frozen on explicit rename

This commit is contained in:
2026-06-13 10:13:57 +03:00
parent 92bfe888b5
commit 4e5108f434
14 changed files with 508 additions and 16 deletions
@@ -89,6 +89,66 @@ describe('SnippetLibrary metadata panel (spec §02)', () => {
expect(useSnippetStore.getState().snippets[0].name).toBe('Renamed');
});
test('a publish-derived rename is adopted by the panel, not reverted by its auto-save', () => {
// Regression: the panel's local name state lagged a publish rename, so its
// debounced auto-save wrote the stale default back — the rename flickered
// for ~400ms in the list and then undid itself.
vi.useFakeTimers();
const s = createSnippet({ id: 'a', now: new Date('2026-01-01T00:00:00Z') }); // auto name
useSnippetStore.getState().hydrate([s], 'a');
act(() => {
root.render(<SnippetLibrary />);
});
act(() => {
useSnippetStore
.getState()
.updateDraft(
JSON.stringify({
mark: 'bar',
encoding: { x: { field: 'Region' }, y: { aggregate: 'count' } },
}),
);
useSnippetStore.getState().publish(new Date('2026-02-01T00:00:00Z'));
});
expect(nameInput().value).toBe('Bar chart of count by Region');
act(() => {
vi.advanceTimersByTime(1000); // any pending auto-save settles
});
expect(useSnippetStore.getState().snippets[0].name).toBe('Bar chart of count by Region');
});
test('a name edit in progress survives a publish rename (user text wins)', () => {
vi.useFakeTimers();
const s = createSnippet({ id: 'a', now: new Date('2026-01-01T00:00:00Z') });
useSnippetStore.getState().hydrate([s], 'a');
act(() => {
root.render(<SnippetLibrary />);
});
act(() => typeInto(nameInput(), 'My Chart')); // diverged, debounce pending
act(() => {
useSnippetStore
.getState()
.updateDraft(
JSON.stringify({
mark: 'bar',
encoding: { x: { field: 'Region' }, y: { aggregate: 'count' } },
}),
);
useSnippetStore.getState().publish();
});
expect(nameInput().value).toBe('My Chart'); // not clobbered by the derived name
act(() => {
vi.advanceTimersByTime(1000);
});
expect(useSnippetStore.getState().snippets[0].name).toBe('My Chart');
});
test('does not loop on a search/sort state change (render-loop guard)', async () => {
// A selector that returned a fresh filtered array would re-render forever
// (MEMORY → "Zustand stable selectors"); the component derives via useMemo.
+13
View File
@@ -99,6 +99,19 @@ function SnippetMeta({
const [name, setName] = useState(snippet.name);
const [comment, setCommentLocal] = useState(snippet.comment);
// The store can rename underneath this panel (publish's content-derived
// naming, spec §03D). Adopt the new store name unless the local field has
// diverged — i.e. the user is mid-edit, and their text wins. Without this,
// the stale local value differs from the store and the auto-save below
// writes the old name right back, silently undoing the publish rename.
const lastStoreName = useRef(snippet.name);
useEffect(() => {
if (snippet.name === lastStoreName.current) return;
const previous = lastStoreName.current;
lastStoreName.current = snippet.name;
setName((local) => (local === previous ? snippet.name : local));
}, [snippet.name]);
useEffect(() => {
if (name === snippet.name) return;
const t = setTimeout(() => renameSnippet(snippet.id, name), META_AUTOSAVE_MS);