mirror of
https://github.com/home-assistant/frontend.git
synced 2026-09-30 08:48:57 +01:00
* Render real trigger/condition/action icons and unfinished branch state in trace graph * Process review: repeat body tracking, service and action icons * Infer empty repeat body entry from the repeat config * Track repeat bodies only by their traced steps
659 lines
22 KiB
TypeScript
659 lines
22 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
|
import type { Action } from "../../src/data/script";
|
|
import type {
|
|
ActionTraceStep,
|
|
AutomationTraceExtended,
|
|
ScriptTraceExtended,
|
|
} from "../../src/data/trace";
|
|
import { TraceTree, type TraceBranch } from "../../src/data/trace-tree";
|
|
|
|
const timestamp = "2026-09-17T00:00:00Z";
|
|
const step = { path: "sequence/0/then/0", timestamp };
|
|
const lastPath = "sequence/0/then/0";
|
|
|
|
const createTrace = (
|
|
sequence: Action[],
|
|
records: ActionTraceStep[] = [],
|
|
overrides: Partial<ScriptTraceExtended> = {}
|
|
): ScriptTraceExtended => ({
|
|
domain: "script",
|
|
item_id: "test",
|
|
run_id: "test",
|
|
state: "stopped",
|
|
script_execution: "finished",
|
|
last_step: records[records.length - 1]?.path ?? null,
|
|
timestamp: { start: timestamp, finish: timestamp },
|
|
context: { id: "test", user_id: null },
|
|
config: { alias: "Test", sequence },
|
|
trace: records.reduce<Record<string, ActionTraceStep[]>>((steps, record) => {
|
|
(steps[record.path] ??= []).push(record);
|
|
return steps;
|
|
}, {}),
|
|
...overrides,
|
|
});
|
|
|
|
const createAutomationTrace = (
|
|
config: AutomationTraceExtended["config"],
|
|
records: ActionTraceStep[] = []
|
|
): AutomationTraceExtended => {
|
|
const { blueprint_inputs: _dropped, ...scriptTrace } = createTrace(
|
|
[],
|
|
records
|
|
);
|
|
return { ...scriptTrace, domain: "automation", trigger: null, config };
|
|
};
|
|
|
|
// Branch completion is observed through the public branch flags, exercising
|
|
// the same code paths the graph renders from.
|
|
const thenBranchOf = (
|
|
steps: Action[],
|
|
records: ActionTraceStep[],
|
|
overrides: Partial<ScriptTraceExtended> = {}
|
|
): TraceBranch =>
|
|
new TraceTree(createTrace([{ if: [], then: steps }], records, overrides))
|
|
.sequence[0].branches[0];
|
|
|
|
describe("TraceTree", () => {
|
|
it("normalizes nested branches and retains unexecuted nodes and original configs", () => {
|
|
const leaf = { delay: 1 };
|
|
const choose = {
|
|
choose: {
|
|
conditions: [],
|
|
sequence: { repeat: { count: 2, sequence: leaf } },
|
|
},
|
|
default: { stop: "Fallback" },
|
|
};
|
|
const leafPath =
|
|
"sequence/0/parallel/0/sequence/0/sequence/0/choose/0/sequence/0/repeat/sequence/0";
|
|
const tree = new TraceTree(
|
|
createTrace(
|
|
[
|
|
{
|
|
parallel: [
|
|
{ sequence: [{ sequence: [choose] }] },
|
|
{ action: "light.turn_on" },
|
|
],
|
|
},
|
|
],
|
|
[{ path: leafPath, timestamp, result: { delay: 1, done: true } }]
|
|
)
|
|
);
|
|
|
|
const [parallel] = tree.sequence;
|
|
const [executed, unexecuted] = parallel.branches;
|
|
const chooseNode = executed.children[0].branches[0].children[0];
|
|
const [choice, fallback] = chooseNode.branches;
|
|
const repeat = choice.children[0];
|
|
const result = repeat.branches[0].children[0];
|
|
expect(chooseNode.config).toBe(choose);
|
|
expect(choice.option).toBe(choose.choose);
|
|
expect(result.config).toBe(leaf);
|
|
expect(result.path).toBe(leafPath);
|
|
expect(result.hasTrace).toBe(true);
|
|
// Descendant records still identify the chosen branch when Core drops its result.
|
|
expect(choice.hasTrace).toBe(true);
|
|
expect(repeat.branches[0].finished).toBe(true);
|
|
expect(fallback.hasTrace).toBe(false);
|
|
expect(fallback.children[0].config).toBe(choose.default);
|
|
expect(unexecuted.children[0].path).toBe(
|
|
"sequence/0/parallel/1/sequence/0"
|
|
);
|
|
// The model classifies a modern `action:` key as a service (which drives
|
|
// the generic node's icon), while the graph keeps rendering the generic
|
|
// node for it, as the old `key in node` lookup did.
|
|
expect(unexecuted.children[0].actionType).toBe("service");
|
|
expect(unexecuted.hasTrace).toBe(false);
|
|
expect(unexecuted.finished).toBe(false);
|
|
});
|
|
|
|
it.each<Action>([{ choose: [] }, { if: [], then: [] }])(
|
|
"distinguishes an implicit bypass from an error for %j",
|
|
(action) => {
|
|
const trace = createTrace([action], [{ path: "sequence/0", timestamp }]);
|
|
const branches = new TraceTree(trace).sequence[0].branches;
|
|
const bypass = branches[branches.length - 1]!;
|
|
expect(bypass.hasTrace).toBe(true);
|
|
expect(bypass.finished).toBe(true);
|
|
expect(bypass.children).toEqual([]);
|
|
|
|
trace.trace["sequence/0"][0].error = "Failed to evaluate condition";
|
|
const failed = new TraceTree(trace).sequence[0];
|
|
expect(failed.error).toBe(true);
|
|
expect(failed.branches.every((branch) => !branch.hasTrace)).toBe(true);
|
|
}
|
|
);
|
|
|
|
it("keeps both branch choices and condition outcomes across repeat iterations", () => {
|
|
const condition = { condition: "template", value_template: "{{ ready }}" };
|
|
const trace = createTrace(
|
|
[{ if: [], then: condition, else: [] }],
|
|
[
|
|
{ path: "sequence/0", timestamp, result: { choice: "then" } },
|
|
{ path: "sequence/0", timestamp, result: { choice: "else" } },
|
|
{ path: "sequence/0/then/0", timestamp, result: { result: false } },
|
|
{ path: "sequence/0/then/0", timestamp, result: { result: true } },
|
|
]
|
|
);
|
|
const [thenBranch, elseBranch] = new TraceTree(trace).sequence[0].branches;
|
|
expect(thenBranch.hasTrace).toBe(true);
|
|
expect(elseBranch.hasTrace).toBe(true);
|
|
expect(thenBranch.children[0].condition).toEqual({
|
|
executed: true,
|
|
passed: true,
|
|
failed: true,
|
|
});
|
|
});
|
|
|
|
it("exposes disabled inheritance, branch state, and presentation fields", () => {
|
|
const tree = new TraceTree(
|
|
createTrace(
|
|
[
|
|
{
|
|
enabled: false,
|
|
choose: [{ conditions: [], sequence: [{ delay: 1 }] }],
|
|
default: [],
|
|
},
|
|
],
|
|
[
|
|
{ path: "sequence/0", timestamp },
|
|
{
|
|
path: "sequence/0/choose/0/sequence/0",
|
|
timestamp,
|
|
result: { delay: 1, done: false },
|
|
},
|
|
]
|
|
)
|
|
);
|
|
const [choose] = tree.sequence;
|
|
expect(choose.actionType).toBe("choose");
|
|
expect(choose.disabled).toBe(true);
|
|
const [choice, fallback] = choose.branches;
|
|
expect(choice.disabled).toBe(true);
|
|
expect(choice.children[0].disabled).toBe(true);
|
|
expect(choice.unfinished).toBe(true);
|
|
expect(fallback.unfinished).toBe(false);
|
|
expect(choice.children[0].actionType).toBe("delay");
|
|
expect(tree.getNode("sequence/0/choose/0")).toEqual({
|
|
path: "sequence/0/choose/0",
|
|
config: { conditions: [], sequence: [{ delay: 1 }] },
|
|
type: "chooseOption",
|
|
});
|
|
});
|
|
|
|
it("masks not-triggered runs but keeps them selectable with navigation", () => {
|
|
const trace = createAutomationTrace(
|
|
{
|
|
alias: "Test",
|
|
triggers: [{ trigger: "state" }],
|
|
actions: [],
|
|
},
|
|
[{ path: "trigger/0", timestamp }]
|
|
);
|
|
const unmasked = new TraceTree({ ...trace, not_triggered: false });
|
|
expect(unmasked.triggers?.[0]).toMatchObject({
|
|
hasTrace: true,
|
|
track: true,
|
|
});
|
|
const masked = new TraceTree({ ...trace, not_triggered: true });
|
|
expect(masked.triggers?.[0]).toMatchObject({
|
|
hasTrace: true,
|
|
track: false,
|
|
notTriggered: true,
|
|
});
|
|
expect(masked.firstTracked).toMatchObject({ path: "trigger/0" });
|
|
expect(masked.previousTracked("trigger/0")).toBeUndefined();
|
|
expect(masked.nextTracked("trigger/0")).toBeUndefined();
|
|
// Unknown paths restart from the first tracked node, as the graph did.
|
|
expect(masked.nextTracked("missing")).toMatchObject({ path: "trigger/0" });
|
|
});
|
|
|
|
it("maps condition outcomes and repeat badges for rendering", () => {
|
|
const tree = new TraceTree(
|
|
createTrace(
|
|
[
|
|
{ condition: "template", value_template: "{{ ready }}" },
|
|
{ repeat: { count: 3, sequence: [{ delay: 1 }] } },
|
|
],
|
|
[
|
|
{
|
|
path: "sequence/0",
|
|
timestamp,
|
|
result: { result: true },
|
|
},
|
|
{ path: "sequence/1", timestamp },
|
|
{
|
|
path: "sequence/1/repeat/sequence/0",
|
|
timestamp,
|
|
changed_variables: { repeat: { index: 3 } },
|
|
result: { delay: 1, done: false },
|
|
},
|
|
]
|
|
)
|
|
);
|
|
const [condition, repeat] = tree.sequence;
|
|
expect(condition.track).toBe(true);
|
|
expect(repeat.badge).toBe(3);
|
|
expect(repeat.branches[0].unfinished).toBe(true);
|
|
expect(tree.trackedPaths).toEqual([
|
|
"sequence/0",
|
|
"sequence/1",
|
|
"sequence/1/repeat/sequence/0",
|
|
]);
|
|
});
|
|
|
|
it("uses canonical trace paths for plural automation keys and nested triggers", () => {
|
|
const tree = new TraceTree(
|
|
createAutomationTrace({
|
|
alias: "Test",
|
|
triggers: [
|
|
{ triggers: [{ trigger: "state", entity_id: "light.test" }] },
|
|
],
|
|
conditions: { condition: "template", value_template: "{{ ready }}" },
|
|
actions: { action: "light.turn_on" },
|
|
})
|
|
);
|
|
expect(tree.triggers?.map((node) => node.path)).toEqual(["trigger/0"]);
|
|
expect(tree.conditions.map((node) => node.path)).toEqual(["condition/0"]);
|
|
expect(tree.actions.map((node) => node.path)).toEqual(["action/0"]);
|
|
expect(tree.sequence).toEqual([]);
|
|
});
|
|
});
|
|
|
|
describe("TraceTree branch completion", () => {
|
|
it("marks only branches with traced steps as tracked", () => {
|
|
const [thenBranch, elseBranch] = new TraceTree(
|
|
createTrace(
|
|
[{ if: [], then: [{ delay: 1 }], else: [{ delay: 2 }] }],
|
|
[step]
|
|
)
|
|
).sequence[0].branches;
|
|
expect(thenBranch.hasTrace).toBe(true);
|
|
expect(elseBranch.hasTrace).toBe(false);
|
|
expect(elseBranch.finished).toBe(false);
|
|
expect(elseBranch.unfinished).toBe(false);
|
|
});
|
|
|
|
it.each<Action>([
|
|
{ wait_template: "{{ false }}", continue_on_timeout: false },
|
|
{ wait_for_trigger: [], continue_on_timeout: false },
|
|
])("does not complete an aborted final wait: %j", (action) => {
|
|
const branch = thenBranchOf(
|
|
[action],
|
|
[
|
|
{
|
|
...step,
|
|
result: { wait: { completed: false, remaining: 0 }, timeout: true },
|
|
},
|
|
],
|
|
{ script_execution: "aborted" }
|
|
);
|
|
expect(branch.finished).toBe(false);
|
|
expect(branch.unfinished).toBe(true);
|
|
});
|
|
|
|
it.each<Action>([
|
|
{ wait_template: "{{ false }}", continue_on_timeout: true },
|
|
{ wait_for_trigger: [], continue_on_timeout: true },
|
|
{ wait_template: "{{ false }}" },
|
|
{ wait_for_trigger: [] },
|
|
])(
|
|
"completes a timed-out wait when continuation is enabled: %j",
|
|
(action) => {
|
|
// Core reports `timeout: true` together with `wait.completed: false`;
|
|
// with `continue_on_timeout` true (or omitted, which defaults to
|
|
// continuing) the branch rejoins instead of stalling.
|
|
const branch = thenBranchOf(
|
|
[action],
|
|
[
|
|
{
|
|
...step,
|
|
result: { wait: { completed: false, remaining: 0 }, timeout: true },
|
|
},
|
|
]
|
|
);
|
|
expect(branch.finished).toBe(true);
|
|
expect(branch.unfinished).toBe(false);
|
|
}
|
|
);
|
|
|
|
it.each([
|
|
[true, 10, false, true],
|
|
[false, 0, undefined, true],
|
|
[false, 0, true, true],
|
|
[false, 0, false, false],
|
|
[false, 10, true, false],
|
|
[false, null, true, false],
|
|
] as const)(
|
|
"handles completed=%s remaining=%s continue_on_timeout=%s",
|
|
(completed, remaining, continueOnTimeout, expected) => {
|
|
// A parallel sibling may be last_step, so wait data must stand on its own.
|
|
const branch = thenBranchOf(
|
|
[
|
|
{
|
|
wait_template: "{{ ready }}",
|
|
continue_on_timeout: continueOnTimeout,
|
|
},
|
|
],
|
|
[{ ...step, result: { wait: { completed, remaining } } }],
|
|
{ last_step: "sequence/1" }
|
|
);
|
|
expect(branch.finished).toBe(expected);
|
|
}
|
|
);
|
|
|
|
it.each(["aborted", "cancelled", "error"] as const)(
|
|
"does not complete the terminal branch when execution is %s",
|
|
(scriptExecution) => {
|
|
const records: ActionTraceStep[] =
|
|
scriptExecution === "cancelled"
|
|
? [step]
|
|
: [{ ...step, error: "Failed" }];
|
|
const finishedIn = (steps: Action[], lastStep?: string) => {
|
|
const trace = createTrace([{ if: [], then: steps }], records, {
|
|
script_execution: scriptExecution,
|
|
...(lastStep !== undefined ? { last_step: lastStep } : {}),
|
|
});
|
|
return new TraceTree(trace).sequence[0].branches[0].finished;
|
|
};
|
|
expect(finishedIn([{ action: "light.turn_on" }])).toBe(false);
|
|
expect(finishedIn([{ sequence: [] }], `${lastPath}/sequence/0`)).toBe(
|
|
false
|
|
);
|
|
expect(
|
|
finishedIn([{ action: "light.turn_on" }], "sequence/0/then/01")
|
|
).toBe(false);
|
|
}
|
|
);
|
|
|
|
it("does not complete the currently running final action", () => {
|
|
expect(
|
|
thenBranchOf([{ delay: 10 }], [step], { state: "running" }).finished
|
|
).toBe(false);
|
|
});
|
|
|
|
it.each(["sequence/0", "then/0", "repeat/sequence/0"])(
|
|
"does not rejoin after a nested stop at %s, even when a sibling ran last",
|
|
(descendant) => {
|
|
const stopPath = `${lastPath}/${descendant}`;
|
|
const baseTrace = createTrace(
|
|
[{ if: [], then: [{ sequence: [] }] }],
|
|
[],
|
|
{
|
|
last_step: "sequence/0/else/0",
|
|
}
|
|
);
|
|
baseTrace.trace = {
|
|
[lastPath]: [step],
|
|
[stopPath]: [
|
|
{ ...step, path: stopPath, result: { stop: "", error: false } },
|
|
],
|
|
};
|
|
const finished = () =>
|
|
new TraceTree(baseTrace).sequence[0].branches[0].finished;
|
|
expect(finished()).toBe(false);
|
|
|
|
// A stop from an earlier invocation cannot stop this one.
|
|
baseTrace.trace[lastPath] = [
|
|
{ ...step, timestamp: "2026-09-17T00:00:01Z" },
|
|
];
|
|
expect(finished()).toBe(true);
|
|
|
|
// A false nested condition returns control to its enclosing sequence.
|
|
baseTrace.trace[lastPath] = [step];
|
|
baseTrace.trace[stopPath] = [
|
|
{ ...step, path: stopPath, result: { result: false } },
|
|
];
|
|
expect(finished()).toBe(true);
|
|
}
|
|
);
|
|
|
|
it.each(["running", "cancelled"] as const)(
|
|
"does not infer parallel completion from a sibling when %s",
|
|
(execution) => {
|
|
const path = "sequence/0/parallel/0/sequence/0";
|
|
const siblingPath = "sequence/0/parallel/1/sequence/0";
|
|
const sibling: ActionTraceStep = {
|
|
...step,
|
|
path: siblingPath,
|
|
timestamp: "2026-09-17T00:00:01Z",
|
|
};
|
|
const finishedIn = (
|
|
steps: Action[],
|
|
record: ActionTraceStep
|
|
): boolean => {
|
|
const trace = createTrace([{ parallel: [{ sequence: steps }] }], [], {
|
|
state: execution === "running" ? "running" : "stopped",
|
|
script_execution: execution === "running" ? "finished" : execution,
|
|
last_step: siblingPath,
|
|
});
|
|
trace.trace = {
|
|
[path]: [record],
|
|
[siblingPath]: [sibling],
|
|
};
|
|
return new TraceTree(trace).sequence[0].branches[0].finished;
|
|
};
|
|
expect(finishedIn([{ action: "script.slow" }], { ...step, path })).toBe(
|
|
false
|
|
);
|
|
expect(
|
|
finishedIn([{ delay: 10 }], {
|
|
...step,
|
|
path,
|
|
result: { delay: 10, done: false },
|
|
})
|
|
).toBe(false);
|
|
expect(
|
|
finishedIn([{ delay: 10 }], {
|
|
...step,
|
|
path,
|
|
result: { delay: 10, done: true },
|
|
})
|
|
).toBe(true);
|
|
}
|
|
);
|
|
|
|
it.each(["aborted", "error"] as const)(
|
|
"keeps successful parallel branches green when a sibling leaves the run %s",
|
|
(scriptExecution) => {
|
|
const path = "sequence/0/parallel/0/sequence/0";
|
|
const siblingPath = "sequence/0/parallel/1/sequence/0";
|
|
const treeFor = (lastStep: string): TraceTree => {
|
|
const trace = createTrace(
|
|
[
|
|
{
|
|
parallel: [
|
|
{ sequence: [{ action: "script.slow" }] },
|
|
{ sequence: [{ action: "script.failing" }] },
|
|
],
|
|
},
|
|
],
|
|
[],
|
|
{ script_execution: scriptExecution, last_step: lastStep }
|
|
);
|
|
trace.trace = {
|
|
[path]: [{ ...step, path }],
|
|
[siblingPath]: [{ ...step, path: siblingPath, error: "Failed" }],
|
|
};
|
|
return new TraceTree(trace);
|
|
};
|
|
const tree = treeFor(siblingPath);
|
|
expect(tree.sequence[0].branches[0].finished).toBe(true);
|
|
expect(tree.sequence[0].branches[1].finished).toBe(false);
|
|
|
|
// A successful sibling can also be the last one to start an action.
|
|
expect(treeFor(path).sequence[0].branches[0].finished).toBe(true);
|
|
}
|
|
);
|
|
|
|
it("uses continuation after a branch as rejoin evidence", () => {
|
|
const checkRejoin = (
|
|
trace: ScriptTraceExtended | AutomationTraceExtended,
|
|
select: (tree: TraceTree) => TraceBranch,
|
|
path: string,
|
|
nextPath: string
|
|
) => {
|
|
trace.trace = {
|
|
[path]: [{ ...step, path }],
|
|
[nextPath]: [
|
|
{ ...step, path: nextPath, timestamp: "2026-09-17T00:00:01Z" },
|
|
],
|
|
};
|
|
expect(select(new TraceTree(trace)).finished).toBe(true);
|
|
// A prior iteration's continuation cannot prove this invocation finished.
|
|
trace.trace[path][0].timestamp = "2026-09-17T00:00:02Z";
|
|
expect(select(new TraceTree(trace)).finished).toBe(false);
|
|
};
|
|
|
|
// Continuation after a nested if rejoins its enclosing parallel branch.
|
|
checkRejoin(
|
|
createTrace(
|
|
[
|
|
{
|
|
parallel: [
|
|
{
|
|
sequence: [
|
|
{ if: [], then: [{ action: "script.slow" }] },
|
|
{ action: "script.next" },
|
|
],
|
|
},
|
|
],
|
|
},
|
|
],
|
|
[],
|
|
{ state: "running" }
|
|
),
|
|
(tree) => tree.sequence[0].branches[0].children[0].branches[0],
|
|
"sequence/0/parallel/0/sequence/0/then/0",
|
|
"sequence/0/parallel/0/sequence/1"
|
|
);
|
|
|
|
// Continuation after a parallel branch rejoins the top-level sequence.
|
|
checkRejoin(
|
|
createTrace(
|
|
[
|
|
{ parallel: [{ sequence: [{ action: "script.slow" }] }] },
|
|
{ action: "script.next" },
|
|
],
|
|
[],
|
|
{ state: "running" }
|
|
),
|
|
(tree) => tree.sequence[0].branches[0],
|
|
"sequence/0/parallel/0/sequence/0",
|
|
"sequence/1"
|
|
);
|
|
|
|
// Same rejoin under the plural automation action key.
|
|
const automationTrace = createAutomationTrace(
|
|
{
|
|
alias: "Test",
|
|
triggers: [],
|
|
actions: [
|
|
{ parallel: [{ sequence: [{ action: "script.slow" }] }] },
|
|
{ action: "script.next" },
|
|
],
|
|
},
|
|
[]
|
|
);
|
|
automationTrace.state = "running";
|
|
checkRejoin(
|
|
automationTrace,
|
|
(tree) => tree.actions[0].branches[0],
|
|
"action/0/parallel/0/sequence/0",
|
|
"action/1"
|
|
);
|
|
});
|
|
|
|
it("allows a disabled wait to be skipped", () => {
|
|
expect(
|
|
thenBranchOf(
|
|
[{ wait_template: "{{ false }}", enabled: false }],
|
|
[{ ...step, result: { enabled: false } }]
|
|
).finished
|
|
).toBe(true);
|
|
});
|
|
|
|
it.each<Action>([
|
|
{ action: "light.turn_on", enabled: false },
|
|
{ stop: "Done", enabled: false },
|
|
{ condition: "template", value_template: "{{ false }}", enabled: false },
|
|
{ delay: 10, enabled: false },
|
|
])("completes a disabled terminal action: %j", (action) => {
|
|
// A disabled final action is skipped by Core, so its branch is done even
|
|
// while a parallel sibling is still running.
|
|
const branch = thenBranchOf(
|
|
[action],
|
|
[{ ...step, result: { enabled: false } }],
|
|
{ state: "running" }
|
|
);
|
|
expect(branch.finished).toBe(true);
|
|
expect(branch.unfinished).toBe(false);
|
|
});
|
|
|
|
it("tracks an empty parallel branch when the parent ran", () => {
|
|
const tree = new TraceTree(
|
|
createTrace(
|
|
[{ parallel: [{ sequence: [] }] }],
|
|
[{ path: "sequence/0", timestamp }]
|
|
)
|
|
);
|
|
const [branch] = tree.sequence[0].branches;
|
|
expect(branch.hasTrace).toBe(true);
|
|
expect(branch.finished).toBe(true);
|
|
expect(branch.unfinished).toBe(false);
|
|
});
|
|
|
|
it("tracks a repeat body only when it was entered", () => {
|
|
const repeatBranch = (sequence: Action[], records: ActionTraceStep[]) =>
|
|
new TraceTree(createTrace([{ repeat: { count: 0, sequence } }], records))
|
|
.sequence[0].branches[0];
|
|
|
|
// The repeat ran but never entered its body, e.g. `count: 0` or a `while`
|
|
// that is false on the first check.
|
|
const notEntered = repeatBranch(
|
|
[{ action: "light.turn_on" }],
|
|
[{ path: "sequence/0", timestamp }]
|
|
);
|
|
expect(notEntered.hasTrace).toBe(false);
|
|
expect(notEntered.unfinished).toBe(false);
|
|
|
|
const entered = repeatBranch(
|
|
[{ action: "light.turn_on" }],
|
|
[
|
|
{ path: "sequence/0", timestamp },
|
|
{ path: "sequence/0/repeat/sequence/0", timestamp },
|
|
]
|
|
);
|
|
expect(entered.hasTrace).toBe(true);
|
|
});
|
|
|
|
it("preserves empty, unreached, error, condition and stop handling", () => {
|
|
const finishedIn = (steps: Action[], records: ActionTraceStep[]) => {
|
|
const trace = createTrace([{ if: [], then: steps }], records, {
|
|
last_step: "sequence/1",
|
|
});
|
|
return new TraceTree(trace).sequence[0].branches[0].finished;
|
|
};
|
|
expect(finishedIn([], [step])).toBe(true);
|
|
expect(finishedIn([{ action: "light.turn_on" }], [step])).toBe(true);
|
|
expect(finishedIn([{ stop: "Done" }], [step])).toBe(false);
|
|
expect(
|
|
finishedIn([{ action: "light.turn_on" }, { delay: 1 }], [step])
|
|
).toBe(false);
|
|
expect(
|
|
finishedIn([{ action: "light.turn_on" }], [{ ...step, error: "Failed" }])
|
|
).toBe(false);
|
|
expect(
|
|
finishedIn(
|
|
[{ action: "light.turn_on", continue_on_error: true }],
|
|
[{ ...step, error: "Failed" }]
|
|
)
|
|
).toBe(true);
|
|
expect(
|
|
finishedIn(
|
|
[{ condition: "template", value_template: "{{ false }}" }],
|
|
[{ ...step, result: { result: false } }]
|
|
)
|
|
).toBe(false);
|
|
});
|
|
});
|