While somebody is on a long-term absence their reports report to the next
management level, and it keeps rolling up until it reaches somebody present.
Derived at read time in lib/acting-manager.ts rather than written to
employees.manager_id: the absent person stays formally in charge, so the
stand-in has to be visible as a stand-in rather than quietly replacing them.
Both ids therefore travel to the UI, and both sides carry a badge — the
absent person ("Abwesend · Vertretung: X") and anyone now reporting
elsewhere ("Vertretung für Y").
Three cases the walk has to survive, all covered by tests:
- Several absent levels in a row — it keeps climbing, and still names the
*recorded* manager as the one being covered for, not the level skipped.
- Everyone above absent — it stops and keeps the recorded manager. Re-rooting
a team to the top of the chart would distort more than showing an absent
manager whose absence is labelled anyway.
- A manager_id cycle, which nothing in the schema forbids.
An absent lead needs no separate deputy field: their stand-in is simply
their own acting manager, the same one their reports moved to.
94 lines
4.1 KiB
TypeScript
94 lines
4.1 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
|
import { resolveActingManager, resolveActingManagers, type ChainNode } from "@/lib/acting-manager";
|
|
|
|
// Nobody's manager_id is rewritten for an absence — the stand-in is derived
|
|
// here, at read time, so the absent person stays formally in charge and the
|
|
// arrangement can be shown as temporary.
|
|
function node(id: string, managerId: string | null, absent = false): ChainNode {
|
|
return { id, managerId, absent };
|
|
}
|
|
|
|
function chain(...nodes: ChainNode[]) {
|
|
return new Map(nodes.map((n) => [n.id, n]));
|
|
}
|
|
|
|
describe("resolveActingManager", () => {
|
|
it("leaves a present manager alone", () => {
|
|
const ic = node("ic", "lead");
|
|
const map = chain(ic, node("lead", "head"));
|
|
expect(resolveActingManager(ic, map)).toEqual({ actingManagerId: "lead", coveredForId: null });
|
|
});
|
|
|
|
it("rolls up one level when the manager is absent", () => {
|
|
const ic = node("ic", "lead");
|
|
const map = chain(ic, node("lead", "head", true), node("head", "ceo"));
|
|
expect(resolveActingManager(ic, map)).toEqual({ actingManagerId: "head", coveredForId: "lead" });
|
|
});
|
|
|
|
it("keeps rolling up while each level is absent", () => {
|
|
const ic = node("ic", "lead");
|
|
const map = chain(ic, node("lead", "head", true), node("head", "ceo", true), node("ceo", null));
|
|
// coveredForId names the *recorded* manager, which is what the employee
|
|
// and the org chart know about — not the intermediate level skipped.
|
|
expect(resolveActingManager(ic, map)).toEqual({ actingManagerId: "ceo", coveredForId: "lead" });
|
|
});
|
|
|
|
it("gives an absent lead the same stand-in as their own reports", () => {
|
|
// The deputy for an absent person is simply their own acting manager,
|
|
// which is why no separate deputy field is needed anywhere.
|
|
const lead = node("lead", "head", true);
|
|
const map = chain(node("ic", "lead"), lead, node("head", "ceo"));
|
|
expect(resolveActingManager(lead, map).actingManagerId).toBe("head");
|
|
});
|
|
|
|
it("keeps the recorded manager when everyone above is absent", () => {
|
|
// Re-rooting the team to the top of the chart would be a bigger
|
|
// distortion than showing the absent manager, whose absence is labelled
|
|
// regardless.
|
|
const ic = node("ic", "lead");
|
|
const map = chain(ic, node("lead", "ceo", true), node("ceo", null, true));
|
|
expect(resolveActingManager(ic, map)).toEqual({ actingManagerId: "lead", coveredForId: null });
|
|
});
|
|
|
|
it("returns nothing for somebody at the top", () => {
|
|
const ceo = node("ceo", null);
|
|
expect(resolveActingManager(ceo, chain(ceo))).toEqual({ actingManagerId: null, coveredForId: null });
|
|
});
|
|
|
|
it("keeps the recorded manager when they are not in the visible set", () => {
|
|
// A manager who had already left or has not started yet; the snapshot's
|
|
// own re-rooting deals with that case.
|
|
const ic = node("ic", "gone");
|
|
expect(resolveActingManager(ic, chain(ic))).toEqual({ actingManagerId: "gone", coveredForId: null });
|
|
});
|
|
|
|
it("terminates on a manager cycle instead of looping forever", () => {
|
|
// Nothing in the schema forbids A -> B -> A.
|
|
const ic = node("ic", "a");
|
|
const map = chain(ic, node("a", "b", true), node("b", "a", true));
|
|
expect(() => resolveActingManager(ic, map)).not.toThrow();
|
|
expect(resolveActingManager(ic, map)).toEqual({ actingManagerId: "a", coveredForId: null });
|
|
});
|
|
|
|
it("terminates when somebody is their own manager", () => {
|
|
const ic = node("ic", "self");
|
|
const map = chain(ic, node("self", "self", true));
|
|
expect(resolveActingManager(ic, map).actingManagerId).toBe("self");
|
|
});
|
|
});
|
|
|
|
describe("resolveActingManagers", () => {
|
|
it("moves a whole team up together", () => {
|
|
const results = resolveActingManagers([
|
|
node("ic1", "lead"),
|
|
node("ic2", "lead"),
|
|
node("lead", "head", true),
|
|
node("head", null),
|
|
]);
|
|
expect(results.get("ic1")).toEqual({ actingManagerId: "head", coveredForId: "lead" });
|
|
expect(results.get("ic2")).toEqual({ actingManagerId: "head", coveredForId: "lead" });
|
|
// The absent lead stays under their own manager rather than vanishing.
|
|
expect(results.get("lead")?.actingManagerId).toBe("head");
|
|
});
|
|
});
|