mirror of
https://github.com/olehomelchenko/astrolabe.git
synced 2026-08-08 02:02:33 +00:00
Snippet naming: content-derived on publish, frozen on explicit rename
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -538,6 +538,9 @@ export const useChartBuilderStore = create<ChartBuilderState>((set, get) => ({
|
||||
// to its dataset (§09F) without extra wiring. Provenance kept in meta (§06).
|
||||
useSnippetStore.getState().createSnippet({
|
||||
name,
|
||||
// Generated, not chosen: stays in the auto naming tier, so publish keeps
|
||||
// the name tracking the spec until the user renames (spec §02 → Naming).
|
||||
nameSource: 'auto',
|
||||
spec: specText,
|
||||
now,
|
||||
meta: { createdWith: 'chart-builder', builtFromDataset: config.datasetName },
|
||||
|
||||
@@ -225,6 +225,98 @@ describe('editorView + selectShownText (spec §03D)', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('publish — content-derived naming (untouched default names only)', () => {
|
||||
const namableSpec = JSON.stringify({
|
||||
mark: 'bar',
|
||||
encoding: { x: { field: 'Ship Mode' }, y: { aggregate: 'count' } },
|
||||
});
|
||||
|
||||
test('publishing upgrades an untouched timestamp name to a content-derived one', () => {
|
||||
const a = createSnippet({ id: 'a', now: new Date('2026-01-01T00:00:00Z') });
|
||||
store().hydrate([a], 'a');
|
||||
|
||||
store().updateDraft(namableSpec);
|
||||
store().publish(new Date('2026-02-01T00:00:00Z'));
|
||||
|
||||
expect(selectActiveSnippet(store())?.name).toBe('Bar chart of count by Ship Mode');
|
||||
});
|
||||
|
||||
test('a user-chosen name is never overwritten', () => {
|
||||
const a = createSnippet({ id: 'a', name: 'My Chart' });
|
||||
store().hydrate([a], 'a');
|
||||
|
||||
store().updateDraft(namableSpec);
|
||||
store().publish();
|
||||
|
||||
expect(selectActiveSnippet(store())?.name).toBe('My Chart');
|
||||
});
|
||||
|
||||
test('an undescribable spec keeps the default name', () => {
|
||||
const a = createSnippet({ id: 'a', now: new Date('2026-01-01T00:00:00Z') });
|
||||
store().hydrate([a], 'a');
|
||||
|
||||
store().updateDraft('{"a":2}');
|
||||
store().publish();
|
||||
|
||||
expect(selectActiveSnippet(store())?.name).toBe(a.name);
|
||||
});
|
||||
|
||||
test('an auto name keeps tracking the spec across publishes', () => {
|
||||
const a = createSnippet({ id: 'a', now: new Date('2026-01-01T00:00:00Z') });
|
||||
store().hydrate([a], 'a');
|
||||
|
||||
store().updateDraft(namableSpec);
|
||||
store().publish();
|
||||
expect(selectActiveSnippet(store())?.name).toBe('Bar chart of count by Ship Mode');
|
||||
|
||||
store().updateDraft(
|
||||
JSON.stringify({
|
||||
mark: 'line',
|
||||
encoding: { x: { field: 'date' }, y: { aggregate: 'sum', field: 'revenue' } },
|
||||
}),
|
||||
);
|
||||
store().publish();
|
||||
expect(selectActiveSnippet(store())?.name).toBe('Line chart of sum of revenue by date');
|
||||
});
|
||||
|
||||
test('a legacy record whose name matches its own derivation stays in the auto tier', () => {
|
||||
// Records written before nameSource existed: a name identical to what the
|
||||
// app derives from the published spec is provably app-picked, so adding a
|
||||
// title and publishing must adopt it (not stay frozen).
|
||||
const legacy = {
|
||||
...createSnippet({ id: 'a', spec: namableSpec, now: new Date('2026-01-01T00:00:00Z') }),
|
||||
name: 'Bar chart of count by Ship Mode',
|
||||
nameSource: undefined,
|
||||
};
|
||||
store().hydrate([legacy], 'a');
|
||||
|
||||
store().updateDraft(
|
||||
JSON.stringify({
|
||||
title: 'Shipments by mode',
|
||||
mark: 'bar',
|
||||
encoding: { x: { field: 'Ship Mode' }, y: { aggregate: 'count' } },
|
||||
}),
|
||||
);
|
||||
store().publish();
|
||||
|
||||
expect(selectActiveSnippet(store())?.name).toBe('Shipments by mode');
|
||||
});
|
||||
|
||||
test('an explicit rename freezes the name against later publishes', () => {
|
||||
const a = createSnippet({ id: 'a', now: new Date('2026-01-01T00:00:00Z') });
|
||||
store().hydrate([a], 'a');
|
||||
|
||||
store().updateDraft(namableSpec);
|
||||
store().publish();
|
||||
store().renameSnippet('a', 'My Chart');
|
||||
|
||||
store().updateDraft(JSON.stringify({ title: 'Something else', mark: 'bar' }));
|
||||
store().publish();
|
||||
|
||||
expect(selectActiveSnippet(store())?.name).toBe('My Chart');
|
||||
});
|
||||
});
|
||||
|
||||
describe('publish — datasetRefs recomputation', () => {
|
||||
const refSpec = (name: string) => JSON.stringify({ data: { name }, mark: 'bar' });
|
||||
|
||||
|
||||
@@ -20,7 +20,9 @@
|
||||
import { create } from 'zustand';
|
||||
import {
|
||||
createSnippet,
|
||||
deriveSnippetName,
|
||||
duplicateSnippet as duplicateSnippetRecord,
|
||||
isAutoNamed,
|
||||
type CreateSnippetOptions,
|
||||
type Snippet,
|
||||
} from '@core/snippet';
|
||||
@@ -260,7 +262,12 @@ export const useSnippetStore = create<SnippetState>((set, get) => ({
|
||||
if (!target || target.name === name) return s; // unknown id or no change
|
||||
const modified = (now ?? new Date()).toISOString();
|
||||
return {
|
||||
snippets: s.snippets.map((x) => (x.id === id ? { ...x, name, modified } : x)),
|
||||
// An explicit rename freezes the name (`nameSource: 'user'`) — publish's
|
||||
// content-derived naming only ever rewrites auto-picked names (spec §02
|
||||
// → Naming & Tags).
|
||||
snippets: s.snippets.map((x) =>
|
||||
x.id === id ? { ...x, name, nameSource: 'user' as const, modified } : x,
|
||||
),
|
||||
};
|
||||
});
|
||||
},
|
||||
@@ -424,9 +431,16 @@ export const useSnippetStore = create<SnippetState>((set, get) => ({
|
||||
s.id === activeSnippetId
|
||||
? // Promote the draft and recompute datasetRefs from the now-published
|
||||
// spec, so the bidirectional snippet↔dataset link mirrors reality
|
||||
// (spec §03D, docs/architecture/07 §3).
|
||||
// (spec §03D, docs/architecture/07 §3). An auto-picked name keeps
|
||||
// tracking the published content — title, else mark + encodings —
|
||||
// and stays auto so the next publish tracks again; a user-chosen
|
||||
// name (`nameSource: 'user'`) is never rewritten (spec §03D →
|
||||
// Publish, §02 → Naming & Tags).
|
||||
{
|
||||
...s,
|
||||
...(isAutoNamed(s)
|
||||
? { name: deriveSnippetName(s.draftSpec) ?? s.name, nameSource: 'auto' as const }
|
||||
: {}),
|
||||
spec: s.draftSpec,
|
||||
datasetRefs: recomputeDatasetRefs(s.draftSpec),
|
||||
modified,
|
||||
|
||||
Reference in New Issue
Block a user