The popup offered [[feature]]/[[milestone]] on every fresh line — right under the last property or straight after a header. Only volunteer them once there's a blank line above (the "two new lines" convention); typing a bare `[` still summons them anywhere. Property keys are unchanged.
160 lines
5.8 KiB
TypeScript
160 lines
5.8 KiB
TypeScript
import { describe, it, expect } from "vitest"
|
|
import { getCompletions } from "./completion"
|
|
|
|
/** Split a `|`-marked string into (source, caret offset). */
|
|
function atCaret(marked: string): [string, number] {
|
|
const caret = marked.indexOf("|")
|
|
return [marked.slice(0, caret) + marked.slice(caret + 1), caret]
|
|
}
|
|
|
|
/** The labels offered at the `|` marker. */
|
|
function labelsAt(marked: string): string[] {
|
|
const [source, caret] = atCaret(marked)
|
|
return getCompletions(source, caret)?.items.map((i) => i.label) ?? []
|
|
}
|
|
|
|
describe("completion context", () => {
|
|
it("offers plan keys on an empty top-level line", () => {
|
|
// Headers wait for a blank line above; at the very top there's nothing above.
|
|
expect(labelsAt("|")).toEqual(["title", "start", "end"])
|
|
})
|
|
|
|
it("offers feature keys inside a [[feature]] block, no headers on the first line", () => {
|
|
expect(labelsAt("[[feature]]\n|")).toEqual([
|
|
"name",
|
|
"start",
|
|
"original",
|
|
"reestimates",
|
|
"delivered",
|
|
"status",
|
|
"learning",
|
|
"note",
|
|
])
|
|
})
|
|
|
|
it("offers milestone keys inside a [[milestone]] block, no headers on the first line", () => {
|
|
expect(labelsAt("[[milestone]]\n|")).toEqual(["name", "week", "requires"])
|
|
})
|
|
|
|
it("keeps offering remaining keys on a single new line, still without headers", () => {
|
|
expect(labelsAt('[[milestone]]\nname = "M"\n|')).toEqual(["week", "requires"])
|
|
})
|
|
|
|
it("volunteers block headers only after a blank line (two new lines)", () => {
|
|
const filled = '[[milestone]]\nname = "M"\nweek = 2026-01-01\nrequires = []'
|
|
// Directly under the last property: nothing left to add ⇒ no popup.
|
|
expect(getCompletions(...atCaret(`${filled}\n|`))).toBeNull()
|
|
// A blank line signals a new block is wanted ⇒ offer the headers.
|
|
expect(labelsAt(`${filled}\n\n|`)).toEqual(["[[feature]]", "[[milestone]]"])
|
|
})
|
|
|
|
it("volunteers headers after a blank line even with keys still open", () => {
|
|
expect(labelsAt('[[milestone]]\nname = "M"\n\n|')).toEqual([
|
|
"week",
|
|
"requires",
|
|
"[[feature]]",
|
|
"[[milestone]]",
|
|
])
|
|
})
|
|
|
|
it("excludes keys already written in the current block", () => {
|
|
const labels = labelsAt('[[feature]]\nname = "X"\noriginal = 2026-06-01\n|')
|
|
expect(labels).not.toContain("name")
|
|
expect(labels).not.toContain("original")
|
|
expect(labels).toContain("start")
|
|
})
|
|
|
|
it("filters keys by the typed prefix", () => {
|
|
expect(labelsAt("[[feature]]\nst|")).toEqual(["start", "status"])
|
|
})
|
|
|
|
it("completes a header from a leading bracket", () => {
|
|
expect(labelsAt("[[f|")).toEqual(["[[feature]]"])
|
|
})
|
|
|
|
it("suggests status values after status =", () => {
|
|
expect(labelsAt("[[feature]]\nstatus = |")).toEqual(["on-track", "at-risk", "off-track"])
|
|
})
|
|
|
|
it("filters status values by the typed prefix, inside an open quote", () => {
|
|
expect(labelsAt('[[feature]]\nstatus = "o|')).toEqual(["on-track", "off-track"])
|
|
})
|
|
|
|
it("offers nothing once the status value's quote is closed", () => {
|
|
expect(getCompletions(...atCaret('[[feature]]\nstatus = "on-track"|'))).toBeNull()
|
|
})
|
|
|
|
it("suggests feature names inside a milestone requires array", () => {
|
|
const source = '[[feature]]\nname = "Payments"\n\n[[milestone]]\nrequires = ["|'
|
|
expect(labelsAt(source)).toEqual(["Payments"])
|
|
})
|
|
|
|
it("does not offer feature names outside a milestone block", () => {
|
|
// `requires` is meaningless at the plan level, so there is nothing to add.
|
|
expect(labelsAt('name = "Payments"\n\n[[feature]]\nrequires = ["|')).toEqual([])
|
|
})
|
|
|
|
it("returns nothing in a value position it cannot complete (a date)", () => {
|
|
expect(getCompletions(...atCaret("[[feature]]\nstart = 2026-|"))).toBeNull()
|
|
})
|
|
})
|
|
|
|
describe("date completion", () => {
|
|
const TODAY = "2026-06-19"
|
|
const datesAt = (marked: string) => {
|
|
const [source, caret] = atCaret(marked)
|
|
return getCompletions(source, caret, TODAY)
|
|
}
|
|
|
|
it("suggests today then the current-year prefix for an empty date value", () => {
|
|
const ctx = datesAt("[[feature]]\nstart = |")!
|
|
expect(ctx.items.map((i) => i.insert)).toEqual(["2026-06-19", "2026-"])
|
|
expect(ctx.items.map((i) => i.detail)).toEqual(["today", "this year"])
|
|
})
|
|
|
|
it("offers the same for plan dates and milestone weeks", () => {
|
|
expect(datesAt("end = |")!.items.map((i) => i.insert)).toEqual(["2026-06-19", "2026-"])
|
|
expect(datesAt("[[milestone]]\nweek = |")!.items.map((i) => i.insert)).toEqual([
|
|
"2026-06-19",
|
|
"2026-",
|
|
])
|
|
})
|
|
|
|
it("inserts at the caret, replacing nothing", () => {
|
|
const [source, caret] = atCaret("[[feature]]\ndelivered = |")
|
|
const ctx = getCompletions(source, caret, TODAY)!
|
|
expect([ctx.from, ctx.to]).toEqual([caret, caret])
|
|
})
|
|
|
|
it("offers a date for a fresh element in a reestimates array", () => {
|
|
expect(datesAt("[[feature]]\nreestimates = [|")!.items.map((i) => i.insert)).toEqual([
|
|
"2026-06-19",
|
|
"2026-",
|
|
])
|
|
expect(
|
|
datesAt("[[feature]]\nreestimates = [2026-01-01, |")!.items.map((i) => i.insert),
|
|
).toEqual(["2026-06-19", "2026-"])
|
|
})
|
|
|
|
it("stays out of the way once a date is being typed", () => {
|
|
expect(datesAt("[[feature]]\nstart = 2|")).toBeNull()
|
|
expect(datesAt("[[feature]]\nstart = 2026-|")).toBeNull()
|
|
expect(datesAt("[[feature]]\nreestimates = [2026-0|")).toBeNull()
|
|
})
|
|
})
|
|
|
|
describe("completion replace range", () => {
|
|
it("replaces the typed prefix, not the whole line", () => {
|
|
const source = "[[feature]]\nst"
|
|
const ctx = getCompletions(source, source.length)!
|
|
expect(source.slice(ctx.from, ctx.to)).toBe("st")
|
|
})
|
|
|
|
it("replaces a partial bracketed header from the bracket", () => {
|
|
const source = "[[f"
|
|
const ctx = getCompletions(source, source.length)!
|
|
expect(source.slice(ctx.from, ctx.to)).toBe("[[f")
|
|
expect(ctx.items[0].insert).toBe("[[feature]]")
|
|
})
|
|
})
|