feat(model): make useMacroplan own a library of named plans
This commit is contained in:
113
src/composables/useMacroplan.test.ts
Normal file
113
src/composables/useMacroplan.test.ts
Normal file
@@ -0,0 +1,113 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { describe, it, expect, beforeEach } from 'vitest'
|
||||
import { nextTick } from 'vue'
|
||||
import { useMacroplan } from './useMacroplan'
|
||||
import { SAMPLE_PLAN } from '../data/sample'
|
||||
|
||||
const LIB_KEY = 'macroplan:library'
|
||||
const LEGACY_KEY = 'macroplan:source'
|
||||
|
||||
beforeEach(() => localStorage.clear())
|
||||
|
||||
describe('useMacroplan — load & migration', () => {
|
||||
it('seeds one sample plan when storage is empty, and persists it immediately', () => {
|
||||
const m = useMacroplan()
|
||||
expect(m.plans.value).toHaveLength(1)
|
||||
expect(m.activeId.value).toBe(m.plans.value[0].id)
|
||||
expect(m.source.value).toBe(SAMPLE_PLAN)
|
||||
expect(localStorage.getItem(LIB_KEY)).toBeTruthy() // survives a reload
|
||||
})
|
||||
|
||||
it('migrates a legacy single-source store into a one-plan library and drops the legacy key', () => {
|
||||
localStorage.setItem(LEGACY_KEY, SAMPLE_PLAN)
|
||||
const m = useMacroplan()
|
||||
expect(m.plans.value).toHaveLength(1)
|
||||
expect(m.source.value).toBe(SAMPLE_PLAN)
|
||||
expect(m.plans.value[0].name).toBe('Q3 — Checkout revamp')
|
||||
expect(localStorage.getItem(LEGACY_KEY)).toBeNull()
|
||||
})
|
||||
|
||||
it('falls back to a fresh sample when the library JSON is corrupt', () => {
|
||||
localStorage.setItem(LIB_KEY, '{ not valid json')
|
||||
const m = useMacroplan()
|
||||
expect(m.plans.value).toHaveLength(1)
|
||||
expect(m.source.value).toBe(SAMPLE_PLAN)
|
||||
})
|
||||
|
||||
it('repairs a stale activeId instead of discarding the stored plans', () => {
|
||||
localStorage.setItem(
|
||||
LIB_KEY,
|
||||
JSON.stringify({
|
||||
version: 1,
|
||||
activeId: 'gone',
|
||||
plans: [{ id: 'x', name: 'Kept', source: SAMPLE_PLAN }],
|
||||
}),
|
||||
)
|
||||
const m = useMacroplan()
|
||||
expect(m.activeId.value).toBe('x')
|
||||
expect(m.plans.value[0].name).toBe('Kept')
|
||||
})
|
||||
})
|
||||
|
||||
describe('useMacroplan — active plan binding', () => {
|
||||
it('refreshes the cached name when the active source parses to a title, and autosaves', async () => {
|
||||
const m = useMacroplan()
|
||||
m.source.value = 'title = "Renamed"\n'
|
||||
await nextTick()
|
||||
expect(m.plans.value[0].name).toBe('Renamed')
|
||||
expect(localStorage.getItem(LIB_KEY)).toContain('Renamed')
|
||||
})
|
||||
|
||||
it('keeps the last-good name and render when the source is mid-edit/broken', async () => {
|
||||
const m = useMacroplan()
|
||||
const goodPlan = m.plan.value
|
||||
m.source.value = 'title = "Renamed"\n[[feature]]\n' // feature missing name → parse error
|
||||
await nextTick()
|
||||
expect(m.error.value).toBeTruthy()
|
||||
expect(m.plan.value).toBe(goodPlan) // last-good render retained
|
||||
expect(m.plans.value[0].name).toBe('Q3 — Checkout revamp') // name unchanged
|
||||
})
|
||||
})
|
||||
|
||||
describe('useMacroplan — CRUD', () => {
|
||||
it('newPlan appends a sample plan and activates it', () => {
|
||||
const m = useMacroplan()
|
||||
const firstId = m.activeId.value
|
||||
m.newPlan()
|
||||
expect(m.plans.value).toHaveLength(2)
|
||||
expect(m.activeId.value).not.toBe(firstId)
|
||||
expect(m.source.value).toBe(SAMPLE_PLAN)
|
||||
})
|
||||
|
||||
it('deletePlan removes the active plan and re-points to the preceding one', () => {
|
||||
const m = useMacroplan()
|
||||
m.newPlan() // 2 plans; second is active
|
||||
const [first, second] = m.plans.value
|
||||
m.deletePlan(second.id)
|
||||
expect(m.plans.value).toHaveLength(1)
|
||||
expect(m.activeId.value).toBe(first.id)
|
||||
})
|
||||
|
||||
it('deleting the last plan re-seeds a fresh sample (never empty)', () => {
|
||||
const m = useMacroplan()
|
||||
m.deletePlan(m.activeId.value)
|
||||
expect(m.plans.value).toHaveLength(1)
|
||||
expect(m.source.value).toBe(SAMPLE_PLAN)
|
||||
})
|
||||
|
||||
it('switching to a broken plan shows its own empty state, not the previous render', async () => {
|
||||
const m = useMacroplan()
|
||||
const aId = m.activeId.value
|
||||
m.newPlan()
|
||||
const bId = m.activeId.value
|
||||
m.source.value = 'title = "B"\n[[feature]]\n' // B broken
|
||||
await nextTick()
|
||||
m.selectPlan(aId)
|
||||
await nextTick()
|
||||
expect(m.plan.value).toBeTruthy() // A renders
|
||||
m.selectPlan(bId)
|
||||
await nextTick()
|
||||
expect(m.error.value).toBeTruthy() // B's parse error surfaces
|
||||
expect(m.plan.value).toBeNull() // no stale A render leaks through
|
||||
})
|
||||
})
|
||||
@@ -1,19 +1,104 @@
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import { ref, shallowRef, computed, watch } from 'vue'
|
||||
import { parseMacroplan } from '../model/parse'
|
||||
import { buildPlan } from '../model/plan'
|
||||
import type { Plan } from '../model/types'
|
||||
import { SAMPLE_PLAN } from '../data/sample'
|
||||
|
||||
const STORAGE_KEY = 'macroplan:source'
|
||||
const STORAGE_KEY = 'macroplan:library'
|
||||
const LEGACY_KEY = 'macroplan:source'
|
||||
|
||||
export interface StoredPlan {
|
||||
id: string
|
||||
name: string
|
||||
source: string
|
||||
}
|
||||
|
||||
interface Library {
|
||||
version: 1
|
||||
activeId: string
|
||||
plans: StoredPlan[]
|
||||
}
|
||||
|
||||
/** The source's title if it fully parses, else null (so the cached name only
|
||||
* ever updates on a valid parse). */
|
||||
function titleOf(source: string): string | null {
|
||||
try {
|
||||
return parseMacroplan(source).title
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function newStoredPlan(source: string): StoredPlan {
|
||||
return { id: crypto.randomUUID(), name: titleOf(source) ?? 'Untitled', source }
|
||||
}
|
||||
|
||||
function save(lib: Library): void {
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(lib))
|
||||
} catch {
|
||||
/* localStorage may be full or blocked — autosave is best-effort */
|
||||
}
|
||||
}
|
||||
|
||||
/** Resolve the initial library: existing store → legacy migration → fresh seed.
|
||||
* Always persisted before returning so a migrated/seeded library survives a
|
||||
* reload even if the user never edits. */
|
||||
function loadLibrary(): Library {
|
||||
let result: Library | null = null
|
||||
|
||||
const raw = localStorage.getItem(STORAGE_KEY)
|
||||
if (raw) {
|
||||
try {
|
||||
const lib = JSON.parse(raw) as Library
|
||||
if (lib && Array.isArray(lib.plans) && lib.plans.length > 0) {
|
||||
if (!lib.plans.some((p) => p.id === lib.activeId)) lib.activeId = lib.plans[0].id
|
||||
result = { version: 1, activeId: lib.activeId, plans: lib.plans }
|
||||
}
|
||||
} catch {
|
||||
/* corrupt JSON → fall through to migration / seed */
|
||||
}
|
||||
}
|
||||
|
||||
if (!result) {
|
||||
const legacy = localStorage.getItem(LEGACY_KEY)
|
||||
if (legacy != null) {
|
||||
const p = newStoredPlan(legacy)
|
||||
localStorage.removeItem(LEGACY_KEY)
|
||||
result = { version: 1, activeId: p.id, plans: [p] }
|
||||
} else {
|
||||
const seed = newStoredPlan(SAMPLE_PLAN)
|
||||
result = { version: 1, activeId: seed.id, plans: [seed] }
|
||||
}
|
||||
}
|
||||
|
||||
save(result)
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Authoring state for a single Macroplan: the TOML source (autosaved to
|
||||
* localStorage), the parsed Plan, and the current parse error. The last
|
||||
* successfully-parsed Plan keeps rendering through transient typos (F3).
|
||||
* Owns the Library — the collection of named Macroplans in localStorage
|
||||
* (ADR-0002). Exposes the active plan's authoring state (source / parsed plan /
|
||||
* error, with the last good render kept through transient typos, F3) plus the
|
||||
* switch / create / delete operations over the library.
|
||||
*/
|
||||
export function useMacroplan() {
|
||||
const source = ref(localStorage.getItem(STORAGE_KEY) ?? SAMPLE_PLAN)
|
||||
const lastGood = ref<Plan | null>(null)
|
||||
const lib = ref<Library>(loadLibrary())
|
||||
const lastGood = shallowRef<Plan | null>(null)
|
||||
|
||||
const active = computed<StoredPlan>(
|
||||
() => lib.value.plans.find((p) => p.id === lib.value.activeId) ?? lib.value.plans[0],
|
||||
)
|
||||
|
||||
const source = computed<string>({
|
||||
get: () => active.value.source,
|
||||
set: (v) => {
|
||||
const p = active.value
|
||||
p.source = v
|
||||
const t = titleOf(v)
|
||||
if (t) p.name = t // refresh the cached label only on a valid parse
|
||||
},
|
||||
})
|
||||
|
||||
const parsed = computed<{ plan: Plan | null; error: string | null }>(() => {
|
||||
try {
|
||||
@@ -31,18 +116,44 @@ export function useMacroplan() {
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
watch(source, (v) => {
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, v)
|
||||
} catch {
|
||||
/* localStorage may be full or blocked — autosave is best-effort */
|
||||
// Persist the whole library on any change (best-effort, like the old autosave).
|
||||
watch(lib, (l) => save(l), { deep: true })
|
||||
|
||||
// Switching plans drops the prior render so a broken target shows its own
|
||||
// error/empty state, not the previous plan's grid.
|
||||
function switchTo(id: string): void {
|
||||
lib.value.activeId = id
|
||||
lastGood.value = parsed.value.plan
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
source,
|
||||
plan: computed(() => parsed.value.plan ?? lastGood.value),
|
||||
error: computed(() => parsed.value.error),
|
||||
plans: computed(() => lib.value.plans.map((p) => ({ id: p.id, name: p.name }))),
|
||||
activeId: computed(() => lib.value.activeId),
|
||||
selectPlan: (id: string) => {
|
||||
if (lib.value.plans.some((p) => p.id === id)) switchTo(id)
|
||||
},
|
||||
newPlan: () => {
|
||||
const p = newStoredPlan(SAMPLE_PLAN)
|
||||
lib.value.plans.push(p)
|
||||
switchTo(p.id)
|
||||
},
|
||||
deletePlan: (id: string) => {
|
||||
const idx = lib.value.plans.findIndex((p) => p.id === id)
|
||||
if (idx === -1) return
|
||||
const wasActive = lib.value.activeId === id
|
||||
lib.value.plans.splice(idx, 1)
|
||||
if (lib.value.plans.length === 0) {
|
||||
const seed = newStoredPlan(SAMPLE_PLAN)
|
||||
lib.value.plans.push(seed)
|
||||
switchTo(seed.id)
|
||||
} else if (wasActive) {
|
||||
switchTo(lib.value.plans[Math.max(0, idx - 1)].id)
|
||||
}
|
||||
},
|
||||
// TODO(task 4): remove once App.vue drops the "Reset to sample" button.
|
||||
resetToSample: () => {
|
||||
source.value = SAMPLE_PLAN
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user