From 5b8551e480cef21f4e9003fe1c1d50cccad3e7e5 Mon Sep 17 00:00:00 2001 From: Felix Kotschenreuther Date: Tue, 7 Jul 2026 15:23:01 +0200 Subject: [PATCH 1/2] feat(plan): group hierarchy as an opt-in managed parents set-field MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Groups already plan (registry campus/group/group-type); this adds group hierarchy to the plan. - DSL: group({ parents: [keys] }) (+ parent sugar). Opt-in — omit to leave a group's hierarchy unmanaged; explicit [] means "no managed parents". Multi-parent (CT hierarchy is a DAG). Parents contribute dependency edges (parents-before-children ordering; cycles are rejected). - engine/hierarchy.ts: parse /groups/hierarchies into id→parentIds; resolve a group's actual managed parents (parent ids ∩ managed → keys, sorted); applyHierarchy folds parents into desired.fields + actual as a set-field so computePlan diffs it generically. Edges to UNMANAGED groups stay invisible (managed-guard). - plan: one bulk /groups/hierarchies fetch when groups are managed. - 7 new tests (hierarchy parse/resolve/apply, DSL parents opt-in/dedup) = 96. Live-verified: config matches ⇒ no changes; drop edge ⇒ ~ parents [x]->[]; add edge ⇒ ~ parents []->[x]; mutual parents ⇒ cycle rejected. Refs #5 --- README.md | 4 +- src/commands/plan.ts | 18 ++++++++- src/config/context.ts | 15 +++++--- src/engine/hierarchy.ts | 84 +++++++++++++++++++++++++++++++++++++++++ src/engine/types.ts | 4 +- tests/context.test.ts | 22 +++++++++++ tests/hierarchy.test.ts | 71 ++++++++++++++++++++++++++++++++++ 7 files changed, 209 insertions(+), 9 deletions(-) create mode 100644 src/engine/hierarchy.ts create mode 100644 tests/hierarchy.test.ts diff --git a/README.md b/README.md index dceab93..2e893b8 100644 --- a/README.md +++ b/README.md @@ -67,7 +67,9 @@ default-exports a function receiving the DSL: ```ts export default (ct) => { ct.campus({ key: "mainz", name: "Mainz", shortName: "MZ" }); - ct.group({ key: "mainz_kids_lead", name: "Mainz · Kids Leitung", parent: "mainz_area" }); + // Hierarchy is opt-in and multi-parent: `parents` are managed group keys. + // Omit it to leave a group's hierarchy unmanaged; edges to unmanaged groups stay invisible. + ct.group({ key: "mainz_kids_lead", name: "Mainz · Kids Leitung", groupTypeId: 2, parents: ["mainz_area"] }); }; ``` diff --git a/src/commands/plan.ts b/src/commands/plan.ts index 45ef94f..0fe31f9 100644 --- a/src/commands/plan.ts +++ b/src/commands/plan.ts @@ -7,6 +7,7 @@ import { loadState, resolveStatePath } from "../state/state.js"; import { loadConfig, resolveConfigPath } from "../config/load.js"; import { computePlan } from "../engine/plan.js"; import { renderPlan } from "../engine/render.js"; +import { parentIdsByGroupId, applyHierarchy, type HierarchyEntry } from "../engine/hierarchy.js"; import { mapConcurrent } from "../util/concurrency.js"; import { info, warn, out } from "../ui.js"; @@ -63,7 +64,22 @@ export function planCommand(): Command { } }); - const plan = computePlan(desired, state, actual, { unresolved }); + // Group hierarchy: one bulk call, folded into each managed group's `parents` set-field. + let parentIds = new Map(); + const hasManagedGroups = Object.values(state.resources).some((m) => m.type === "group"); + if (hasManagedGroups) { + try { + const raw = await client.get("/groups/hierarchies"); + parentIds = parentIdsByGroupId(Array.isArray(raw) ? raw : []); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + fetchErrors.push(`group hierarchies: ${message}`); + warn(`Failed to fetch group hierarchies: ${message}`); + } + } + const desiredWithHierarchy = applyHierarchy(desired, state, actual, parentIds); + + const plan = computePlan(desiredWithHierarchy, state, actual, { unresolved }); if (opts.json) { out(plan); } else { diff --git a/src/config/context.ts b/src/config/context.ts index ace9e59..3f6be05 100644 --- a/src/config/context.ts +++ b/src/config/context.ts @@ -14,7 +14,10 @@ import type { DesiredResource } from "../engine/types.js"; export interface ResourceInput { key: string; + /** Single parent group (sugar for `parents: [parent]`). */ parent?: string; + /** Managed parent groups (hierarchy). Opt-in: omit to leave a group's hierarchy unmanaged. */ + parents?: string[]; dependsOn?: string[]; [field: string]: unknown; } @@ -31,15 +34,15 @@ export interface ConfigContext { export type ConfigModule = (ct: ConfigContext) => void | Promise; function toDesired(type: string, input: ResourceInput): DesiredResource { - const { key, parent, dependsOn = [], ...fields } = input; + const { key, parent, parents, dependsOn = [], ...fields } = input; if (!key || typeof key !== "string") { throw new Error(`${type} declaration is missing a string "key".`); } - const edges = [...dependsOn]; - if (parent) { - edges.push(parent); - } - return { type, key, fields, parent, dependsOn: edges }; + // Hierarchy is opt-in: `parents`/`parent` present (even as []) means "manage this group's parents". + const declared = parents !== undefined || parent !== undefined; + const parentKeys = declared ? [...new Set([...(parents ?? []), ...(parent ? [parent] : [])])] : undefined; + const edges = [...dependsOn, ...(parentKeys ?? [])]; + return { type, key, fields, parent, parents: parentKeys, dependsOn: edges }; } export function createContext(): { ct: ConfigContext; resources: DesiredResource[] } { diff --git a/src/engine/hierarchy.ts b/src/engine/hierarchy.ts new file mode 100644 index 0000000..fc19e42 --- /dev/null +++ b/src/engine/hierarchy.ts @@ -0,0 +1,84 @@ +/** + * Group hierarchy (parent/child) is a many-to-many DAG in ChurchTools: a group + * can have several parents. `GET /groups/hierarchies` returns, per group, the + * ids of its parents and children. + * + * We surface hierarchy in the plan as an opt-in `parents` set-field on a group, + * resolved to logical keys and **restricted to managed groups** — an edge to an + * unmanaged group is invisible (managed-guard), never diffed or proposed for + * removal. + */ + +import type { State } from "../state/state.js"; +import type { DesiredResource } from "./types.js"; + +export interface HierarchyEntry { + groupId: number; + parents?: number[]; + children?: number[]; +} + +/** Map each group id to its parent group ids. */ +export function parentIdsByGroupId(entries: HierarchyEntry[]): Map { + const map = new Map(); + for (const entry of entries) { + if (typeof entry.groupId === "number") { + map.set(entry.groupId, Array.isArray(entry.parents) ? entry.parents : []); + } + } + return map; +} + +/** + * The managed parent keys for a group: its parent ids that are themselves under + * management, mapped to their logical keys and sorted (stable for set diffing). + */ +export function managedParentKeys(parentIds: number[], groupIdToKey: Map): string[] { + const keys: string[] = []; + for (const id of parentIds) { + const key = groupIdToKey.get(id); + if (key !== undefined) { + keys.push(key); + } + } + return keys.sort(); +} + +/** + * Fold group hierarchy into the plan inputs as a `parents` set-field. + * + * Mutates `actual`: every managed group gets `parents` set to its managed + * parent keys (from `parentIdsByGroup`). Returns a new desired list where each + * group that opted into hierarchy (`parents !== undefined`) carries a sorted + * `parents` field, so `computePlan` diffs it generically. Groups that did not + * opt in are untouched — their hierarchy is not managed. + */ +export function applyHierarchy( + desired: DesiredResource[], + state: State, + actual: Map>, + parentIdsByGroup: Map, +): DesiredResource[] { + const groupIdToKey = new Map(); + for (const managed of Object.values(state.resources)) { + if (managed.type === "group") { + groupIdToKey.set(managed.id, managed.key); + } + } + + for (const managed of Object.values(state.resources)) { + if (managed.type !== "group") { + continue; + } + const a = actual.get(managed.key); + if (a) { + a.parents = managedParentKeys(parentIdsByGroup.get(managed.id) ?? [], groupIdToKey); + } + } + + return desired.map((d) => + d.type === "group" && d.parents !== undefined + ? { ...d, fields: { ...d.fields, parents: [...d.parents].sort() } } + : d, + ); +} diff --git a/src/engine/types.ts b/src/engine/types.ts index 66ac111..7808891 100644 --- a/src/engine/types.ts +++ b/src/engine/types.ts @@ -10,7 +10,9 @@ export interface DesiredResource { fields: Record; /** Parent logical key (group hierarchy). Also contributes a dependency edge. */ parent?: string; - /** Logical keys this resource must be applied after (includes `parent`). */ + /** Managed parent group keys (hierarchy). `undefined` = hierarchy not managed for this group; `[]` = should have no managed parents. */ + parents?: string[]; + /** Logical keys this resource must be applied after (includes `parent`/`parents`). */ dependsOn: string[]; } diff --git a/tests/context.test.ts b/tests/context.test.ts index fd3375b..6423195 100644 --- a/tests/context.test.ts +++ b/tests/context.test.ts @@ -30,6 +30,28 @@ describe("config context", () => { expect(resources[0]?.dependsOn).toEqual(["type_x", "lead"]); }); + it("captures multiple parents (hierarchy is a DAG) as an opt-in set, not in fields", () => { + const { ct, resources } = createContext(); + ct.group({ key: "team", name: "Team", parents: ["area_a", "area_b"] }); + expect(resources[0]?.parents).toEqual(["area_a", "area_b"]); + expect(resources[0]?.dependsOn).toEqual(["area_a", "area_b"]); + expect(resources[0]?.fields).not.toHaveProperty("parents"); + }); + + it("treats parents as opt-in: omitted → undefined, explicit [] → managed-empty", () => { + const { ct, resources } = createContext(); + ct.group({ key: "no_hierarchy", name: "N" }); + ct.group({ key: "empty_hierarchy", name: "E", parents: [] }); + expect(resources[0]?.parents).toBeUndefined(); + expect(resources[1]?.parents).toEqual([]); + }); + + it("de-duplicates parent + parents", () => { + const { ct, resources } = createContext(); + ct.group({ key: "team", name: "T", parent: "lead", parents: ["lead", "other"] }); + expect(resources[0]?.parents).toEqual(["lead", "other"]); + }); + it("rejects a duplicate logical key", () => { const { ct } = createContext(); ct.campus({ key: "mainz", name: "A" }); diff --git a/tests/hierarchy.test.ts b/tests/hierarchy.test.ts new file mode 100644 index 0000000..46669ea --- /dev/null +++ b/tests/hierarchy.test.ts @@ -0,0 +1,71 @@ +import { describe, it, expect } from "vitest"; +import { parentIdsByGroupId, managedParentKeys, applyHierarchy } from "../src/engine/hierarchy.js"; +import type { DesiredResource } from "../src/engine/types.js"; +import type { State, ManagedResource } from "../src/state/state.js"; + +function group(key: string, id: number): ManagedResource { + return { type: "group", id, key, fields: { name: key }, adoptedAt: "t", updatedAt: "t" }; +} + +describe("parentIdsByGroupId", () => { + it("maps group ids to their parent id lists", () => { + const map = parentIdsByGroupId([ + { groupId: 1311, parents: [8, 119, 1175], children: [] }, + { groupId: 8, children: [1311] }, + ]); + expect(map.get(1311)).toEqual([8, 119, 1175]); + expect(map.get(8)).toEqual([]); // no parents key → empty + }); +}); + +describe("managedParentKeys", () => { + it("keeps only managed parents, mapped to keys and sorted", () => { + const idToKey = new Map([ + [8, "team_kids"], + [119, "not_relevant"], + ]); + // 1175 is unmanaged → dropped; result sorted + expect(managedParentKeys([1175, 119, 8], idToKey)).toEqual(["not_relevant", "team_kids"]); + }); +}); + +describe("applyHierarchy", () => { + const state: State = { + version: 1, + host: "h", + resources: { child: group("child", 1311), parent: group("parent", 8) }, + }; + + it("injects actual managed parents and augments opted-in desired groups", () => { + const actual = new Map>([ + ["child", { name: "child" }], + ["parent", { name: "parent" }], + ]); + const parentIds = new Map([ + [1311, [8, 119, 1175]], // only 8 is managed + [8, []], + ]); + const desired: DesiredResource[] = [ + { type: "group", key: "child", fields: { name: "child" }, parents: ["parent"], dependsOn: ["parent"] }, + { type: "group", key: "parent", fields: { name: "parent" }, dependsOn: [] }, // no opt-in + ]; + + const out = applyHierarchy(desired, state, actual, parentIds); + + expect(actual.get("child")?.parents).toEqual(["parent"]); // 119/1175 unmanaged → dropped + expect(actual.get("parent")?.parents).toEqual([]); + expect(out[0]?.fields.parents).toEqual(["parent"]); + expect(out[1]?.fields.parents).toBeUndefined(); // group that did not opt in is untouched + }); + + it("leaves non-group resources untouched", () => { + const campusState: State = { + version: 1, + host: "h", + resources: { mz: { type: "campus", id: 0, key: "mz", fields: {}, adoptedAt: "t", updatedAt: "t" } }, + }; + const actual = new Map>([["mz", { name: "Mainz" }]]); + applyHierarchy([], campusState, actual, new Map()); + expect(actual.get("mz")).not.toHaveProperty("parents"); + }); +}); From b82b4bcf342c1542e8141eabaed65d639f794c99 Mon Sep 17 00:00:00 2001 From: Felix Kotschenreuther Date: Tue, 7 Jul 2026 17:55:16 +0200 Subject: [PATCH 2/2] fix(plan): address review of group-hierarchy PR MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resolve correctness/altitude findings from the code review of the opt-in `parents` hierarchy feature: - Decouple `parent` from managed hierarchy. In `main` `parent` was an ordering hint only; the PR silently turned it into a diffed field, so a group with `parent: ` (or any non-group/unmanaged key) produced a permanent, non-convergent `parents` diff. `parent` is now purely a dependency edge again; hierarchy is managed solely via `parents`. - Validate that every `parents` key references a group declared in the same config (reject typos / non-group / unmanaged parents up front instead of emitting an unconvergeable plan). Fixes the broken README example, which referenced an undeclared `mainz_area`. - On a `/groups/hierarchies` fetch failure, leave `parents` undiffed rather than folding an empty map — the old path fabricated spurious "add all parents" changes while claiming the diff was merely "missing". - `applyHierarchy` now only injects `actual.parents` for groups that opted in, matching the desired-side guard (no phantom pseudo-field on pristine records) and honouring its own docstring. - Validate `parent`/`parents` input types (reject a non-array `parents`, a string spread into per-character keys, etc.); treat a falsy `parent` as absent. - Document that `fields.parents` is a hierarchy pseudo-field (logical keys) that apply must route to the per-edge endpoint, and that hierarchy edits surface as updates, not drift (no adopt baseline). Tests: 101 (+5). Typecheck, eslint, prettier clean. Claude-Session: https://claude.ai/code/session_01XmRK4CSYw1jZUkeBFM3abe --- README.md | 6 +++-- src/commands/plan.ts | 8 ++++-- src/config/context.ts | 57 +++++++++++++++++++++++++++++++++++------ src/engine/hierarchy.ts | 25 +++++++++++++----- src/engine/types.ts | 2 +- tests/context.test.ts | 40 +++++++++++++++++++++++++++-- tests/hierarchy.test.ts | 21 ++++++++++++++- 7 files changed, 137 insertions(+), 22 deletions(-) diff --git a/README.md b/README.md index 2e893b8..79a98d9 100644 --- a/README.md +++ b/README.md @@ -67,8 +67,10 @@ default-exports a function receiving the DSL: ```ts export default (ct) => { ct.campus({ key: "mainz", name: "Mainz", shortName: "MZ" }); - // Hierarchy is opt-in and multi-parent: `parents` are managed group keys. - // Omit it to leave a group's hierarchy unmanaged; edges to unmanaged groups stay invisible. + ct.group({ key: "mainz_area", name: "Mainz · Bereiche", groupTypeId: 2 }); + // Hierarchy is opt-in and multi-parent: `parents` are managed group keys, each declared + // in this config. Omit it to leave a group's hierarchy unmanaged; edges to unmanaged + // groups stay invisible. (`parent:` is unrelated — an ordering hint only, not hierarchy.) ct.group({ key: "mainz_kids_lead", name: "Mainz · Kids Leitung", groupTypeId: 2, parents: ["mainz_area"] }); }; ``` diff --git a/src/commands/plan.ts b/src/commands/plan.ts index 0fe31f9..1fb3e1f 100644 --- a/src/commands/plan.ts +++ b/src/commands/plan.ts @@ -64,8 +64,9 @@ export function planCommand(): Command { } }); - // Group hierarchy: one bulk call, folded into each managed group's `parents` set-field. + // Group hierarchy: one bulk call, folded into each opted-in group's `parents` set-field. let parentIds = new Map(); + let hierarchyOk = true; const hasManagedGroups = Object.values(state.resources).some((m) => m.type === "group"); if (hasManagedGroups) { try { @@ -75,9 +76,12 @@ export function planCommand(): Command { const message = err instanceof Error ? err.message : String(err); fetchErrors.push(`group hierarchies: ${message}`); warn(`Failed to fetch group hierarchies: ${message}`); + hierarchyOk = false; } } - const desiredWithHierarchy = applyHierarchy(desired, state, actual, parentIds); + // On a hierarchy-fetch failure, leave `parents` undiffed rather than folding an empty map + // (which would fabricate spurious "add all parents" changes). The plan is flagged INCOMPLETE below. + const desiredWithHierarchy = hierarchyOk ? applyHierarchy(desired, state, actual, parentIds) : desired; const plan = computePlan(desiredWithHierarchy, state, actual, { unresolved }); if (opts.json) { diff --git a/src/config/context.ts b/src/config/context.ts index 3f6be05..57fd1c6 100644 --- a/src/config/context.ts +++ b/src/config/context.ts @@ -4,7 +4,8 @@ * * export default (ct: ConfigContext) => { * ct.campus({ key: "mainz", name: "Mainz", shortName: "MZ" }); - * ct.group({ key: "mainz_kids_lead", name: "Mainz · Kids Leitung", parent: "mainz_area" }); + * ct.group({ key: "mainz_area", name: "Mainz · Bereiche", groupTypeId: 2 }); + * ct.group({ key: "mainz_kids", name: "Mainz · Kids", groupTypeId: 2, parents: ["mainz_area"] }); * }; * * The context is injected (no global state), so blueprints are just functions @@ -14,9 +15,13 @@ import type { DesiredResource } from "../engine/types.js"; export interface ResourceInput { key: string; - /** Single parent group (sugar for `parents: [parent]`). */ + /** Ordering hint: apply this resource after `parent`. A dependency edge only — NOT managed hierarchy. */ parent?: string; - /** Managed parent groups (hierarchy). Opt-in: omit to leave a group's hierarchy unmanaged. */ + /** + * Managed parent groups (group→group hierarchy). Opt-in: omit to leave a group's hierarchy + * unmanaged; `[]` means "managed with no parents". Each key must reference a group declared + * in the same config. + */ parents?: string[]; dependsOn?: string[]; [field: string]: unknown; @@ -38,11 +43,46 @@ function toDesired(type: string, input: ResourceInput): DesiredResource { if (!key || typeof key !== "string") { throw new Error(`${type} declaration is missing a string "key".`); } - // Hierarchy is opt-in: `parents`/`parent` present (even as []) means "manage this group's parents". - const declared = parents !== undefined || parent !== undefined; - const parentKeys = declared ? [...new Set([...(parents ?? []), ...(parent ? [parent] : [])])] : undefined; - const edges = [...dependsOn, ...(parentKeys ?? [])]; - return { type, key, fields, parent, parents: parentKeys, dependsOn: edges }; + // A nullish/empty `parent` is "no parent", not an opt-in to managed-empty hierarchy. + if (parent != null && typeof parent !== "string") { + throw new Error(`${type} "${key}": "parent" must be a string key.`); + } + if (parents !== undefined && (!Array.isArray(parents) || parents.some((p) => typeof p !== "string"))) { + throw new Error(`${type} "${key}": "parents" must be an array of string group keys.`); + } + // `parent` is an ordering hint only — a dependency edge, never a diffed/managed field + // (its pre-hierarchy meaning; a `parent` may point at a campus). Group hierarchy is + // managed opt-in via `parents`: `undefined` → unmanaged, `[]` → managed with no parents. + const parentKey = typeof parent === "string" && parent !== "" ? parent : undefined; + const parentKeys = parents !== undefined ? [...new Set(parents)] : undefined; + const edges = [...new Set([...dependsOn, ...(parentKey ? [parentKey] : []), ...(parentKeys ?? [])])]; + return { type, key, fields, parent: parentKey, parents: parentKeys, dependsOn: edges }; +} + +/** + * Every managed hierarchy parent must reference a group declared in the same config. + * A parent that resolves to nothing (typo, unmanaged group) or to a non-group would + * diff forever against the managed-only actual side, so reject it up front rather than + * emit a plan that can never converge. + */ +function validateReferences(resources: DesiredResource[]): void { + const byKey = new Map(resources.map((r) => [r.key, r])); + for (const r of resources) { + for (const parentKey of r.parents ?? []) { + const target = byKey.get(parentKey); + if (!target) { + throw new Error( + `Group "${r.key}" declares hierarchy parent "${parentKey}", which is not declared in this config. ` + + `Managed parents must reference a group by its key (omit unmanaged parents entirely).`, + ); + } + if (target.type !== "group") { + throw new Error( + `Group "${r.key}" declares hierarchy parent "${parentKey}", but "${parentKey}" is a ${target.type}, not a group.`, + ); + } + } + } } export function createContext(): { ct: ConfigContext; resources: DesiredResource[] } { @@ -75,5 +115,6 @@ export function createContext(): { ct: ConfigContext; resources: DesiredResource export async function evaluateConfig(mod: ConfigModule): Promise { const { ct, resources } = createContext(); await mod(ct); + validateReferences(resources); return resources; } diff --git a/src/engine/hierarchy.ts b/src/engine/hierarchy.ts index fc19e42..68c60fa 100644 --- a/src/engine/hierarchy.ts +++ b/src/engine/hierarchy.ts @@ -47,11 +47,18 @@ export function managedParentKeys(parentIds: number[], groupIdToKey: Map d.type === "group" && d.parents !== undefined).map((d) => d.key), + ); + for (const managed of Object.values(state.resources)) { - if (managed.type !== "group") { + if (managed.type !== "group" || !optedIn.has(managed.key)) { continue; } const a = actual.get(managed.key); diff --git a/src/engine/types.ts b/src/engine/types.ts index 7808891..cfc1fe6 100644 --- a/src/engine/types.ts +++ b/src/engine/types.ts @@ -8,7 +8,7 @@ export interface DesiredResource { key: string; /** Managed fields — same shape as the Phase 2 registry snapshot, so they diff like-for-like against actual. */ fields: Record; - /** Parent logical key (group hierarchy). Also contributes a dependency edge. */ + /** Ordering hint: a dependency edge only (may point at a campus). NOT managed hierarchy — see `parents`. */ parent?: string; /** Managed parent group keys (hierarchy). `undefined` = hierarchy not managed for this group; `[]` = should have no managed parents. */ parents?: string[]; diff --git a/tests/context.test.ts b/tests/context.test.ts index 6423195..bf5ab10 100644 --- a/tests/context.test.ts +++ b/tests/context.test.ts @@ -46,12 +46,48 @@ describe("config context", () => { expect(resources[1]?.parents).toEqual([]); }); - it("de-duplicates parent + parents", () => { + it("de-duplicates the `parents` set", () => { const { ct, resources } = createContext(); - ct.group({ key: "team", name: "T", parent: "lead", parents: ["lead", "other"] }); + ct.group({ key: "team", name: "T", parents: ["lead", "other", "lead"] }); expect(resources[0]?.parents).toEqual(["lead", "other"]); }); + it("keeps `parent` out of managed hierarchy: it is an ordering edge only, never in `parents`", () => { + const { ct, resources } = createContext(); + ct.group({ key: "team", name: "T", parent: "lead", parents: ["other"] }); + expect(resources[0]?.parent).toBe("lead"); + expect(resources[0]?.parents).toEqual(["other"]); // `lead` is NOT folded into managed parents + expect(resources[0]?.dependsOn).toEqual(["lead", "other"]); // both still contribute ordering edges + }); + + it("treats a falsy `parent` as absent, not as opt-in to managed-empty hierarchy", () => { + const { ct, resources } = createContext(); + ct.group({ key: "g", name: "G", parent: "" }); + expect(resources[0]?.parents).toBeUndefined(); // no hierarchy management + expect(resources[0]?.dependsOn).toEqual([]); + }); + + it("rejects a non-array / non-string `parents`", () => { + const { ct } = createContext(); + expect(() => ct.group({ key: "g", name: "G", parents: "area" as never })).toThrow(/array of string/); + expect(() => ct.group({ key: "h", name: "H", parents: [1] as never })).toThrow(/array of string/); + }); + + it("rejects a hierarchy parent that is not a declared group", async () => { + await expect( + evaluateConfig((ct) => { + ct.campus({ key: "mz", name: "Mainz" }); + ct.group({ key: "kids", name: "Kids", parents: ["mz"] }); // mz is a campus + }), + ).rejects.toThrow(/is a campus, not a group/); + + await expect( + evaluateConfig((ct) => { + ct.group({ key: "kids", name: "Kids", parents: ["ghost"] }); // never declared + }), + ).rejects.toThrow(/not declared in this config/); + }); + it("rejects a duplicate logical key", () => { const { ct } = createContext(); ct.campus({ key: "mainz", name: "A" }); diff --git a/tests/hierarchy.test.ts b/tests/hierarchy.test.ts index 46669ea..d488889 100644 --- a/tests/hierarchy.test.ts +++ b/tests/hierarchy.test.ts @@ -53,11 +53,30 @@ describe("applyHierarchy", () => { const out = applyHierarchy(desired, state, actual, parentIds); expect(actual.get("child")?.parents).toEqual(["parent"]); // 119/1175 unmanaged → dropped - expect(actual.get("parent")?.parents).toEqual([]); + // "parent" did not opt into hierarchy → its actual is left untouched (no injected pseudo-field). + expect(actual.get("parent")).not.toHaveProperty("parents"); expect(out[0]?.fields.parents).toEqual(["parent"]); expect(out[1]?.fields.parents).toBeUndefined(); // group that did not opt in is untouched }); + it("injects an empty actual.parents for an opted-in group whose managed parents are all gone", () => { + // A group that opts in (parents: []) but still has a managed parent in CT must see the removal. + const actual = new Map>([ + ["child", { name: "child" }], + ["parent", { name: "parent" }], + ]); + const parentIds = new Map([[1311, [8]]]); // CT still links child -> parent(8, managed) + const desired: DesiredResource[] = [ + { type: "group", key: "child", fields: { name: "child" }, parents: [], dependsOn: [] }, // managed-empty + { type: "group", key: "parent", fields: { name: "parent" }, dependsOn: [] }, + ]; + + const out = applyHierarchy(desired, state, actual, parentIds); + + expect(actual.get("child")?.parents).toEqual(["parent"]); // real managed parent surfaced... + expect(out[0]?.fields.parents).toEqual([]); // ...against a desired of none → diff proposes removal + }); + it("leaves non-group resources untouched", () => { const campusState: State = { version: 1,