1
0
Fork 0

Dialog coverage test

This commit is contained in:
Pabloader 2026-04-26 14:08:32 +00:00
parent 8fd0d317cf
commit ad327b17d7
3 changed files with 237 additions and 63 deletions

View File

@ -39,12 +39,6 @@ function parseCondition(s: string): ParsedCondition {
return { variable, negate: false, operator: operator as ConditionOperator, value };
}
function conditionVariable(s: string): string {
if (s.startsWith('~')) return s.slice(1);
const space = s.indexOf(' ');
return space === -1 ? s : s.slice(0, space);
}
export type DialogVariables = Record<string, string | number | boolean>;
export type DialogActions = Record<string, (arg?: unknown) => Promise<void> | void>;
@ -54,22 +48,43 @@ export interface DialogRuntimeOptions {
}
export interface DialogNodeView {
id: string;
nextNodeId?: string;
speaker: string;
text: string;
choices?: DialogChoice[];
}
export function validateDialog(
export namespace Dialogs {
export function getConditions(dialog: Dialog): ParsedCondition[] {
const conditions = new Map<string, ParsedCondition>();
for (const node of dialog.nodes) {
for (const condition of node.conditions ?? []) {
const parsed = parseCondition(condition);
conditions.set(condition, parsed);
}
}
return Array.from(conditions.values());
}
export function getPossibleActions(dialog: Dialog) {
const actions = new Set<string>();
for (const node of dialog.nodes) {
for (const action of node.actions ?? []) {
actions.add(action.type);
}
}
}
export function validate(
dialog: unknown,
conditions: string[],
actions: string[],
speakers: string[],
): string[] {
): string[] {
if (!isDialog(dialog)) return ['invalid dialog structure'];
const errors: string[] = [];
const nodeIds = new Set(dialog.nodes.map(n => n.id));
const conditionSet = new Set(conditions);
const actionSet = new Set(actions);
const speakerSet = new Set(speakers);
@ -77,37 +92,152 @@ export function validateDialog(
errors.push(`startNodeId '${dialog.startNodeId}' does not match any node id`);
for (const node of dialog.nodes) {
if (!speakerSet.has(node.speaker))
if (!speakerSet.has(node.speaker)) {
errors.push(`node '${node.id}': unknown speaker '${node.speaker}'`);
}
if (node.nextNodeId !== undefined && !nodeIds.has(node.nextNodeId))
errors.push(`node '${node.id}': nextNodeId '${node.nextNodeId}' does not match any node id`);
for (const cond of node.conditions ?? []) {
const varName = conditionVariable(cond);
if (!conditionSet.has(varName))
errors.push(`node '${node.id}': unknown condition variable '${varName}'`);
for (const action of node.actions ?? []) {
if (!actionSet.has(action.type)) {
errors.push(`node '${node.id}': unknown action type '${action.type}'`);
}
}
for (const action of node.actions ?? [])
if (!actionSet.has(action.type))
errors.push(`node '${node.id}': unknown action type '${action.type}'`);
for (const choice of node.choices ?? [])
if (choice.nextNodeId !== undefined && !nodeIds.has(choice.nextNodeId))
for (const choice of node.choices ?? []) {
if (choice.nextNodeId !== undefined && !nodeIds.has(choice.nextNodeId)) {
errors.push(`node '${node.id}': choice '${choice.text}': nextNodeId '${choice.nextNodeId}' does not match any node id`);
}
}
}
return errors;
}
}
export function isValidDialog(
export function isValid(
v: unknown,
conditions: string[],
actions: string[],
speakers: string[],
): v is Dialog {
return validateDialog(v, conditions, actions, speakers).length === 0;
): v is Dialog {
return validate(v, actions, speakers).length === 0;
}
type TestValue = string | number | boolean | undefined;
type TestVariables = Record<string, TestValue>;
export interface CoverageIssue {
nodeId: string;
description: string;
combinations: TestVariables[];
}
function inferCandidateValues(conditions: ParsedCondition[]): Map<string, TestValue[]> {
const map = new Map<string, TestValue[]>();
const add = (variable: string, ...values: TestValue[]) => {
const existing = map.get(variable) ?? [];
for (const v of values)
if (!existing.includes(v)) existing.push(v);
map.set(variable, existing);
};
for (const { variable, operator, value } of conditions) {
if (operator === undefined) {
add(variable, undefined, true, false);
} else if (operator === 'null' || operator === '~null') {
add(variable, undefined, true);
} else if (operator === '==' || operator === '!=') {
let other: TestValue;
if (typeof value === 'number') other = value + 1;
else if (typeof value === 'string') other = value === '' ? '_' : '';
else if (typeof value === 'boolean') other = !value;
else other = true;
add(variable, value as TestValue, other);
} else {
const n = value as number;
add(variable, n - 1, n, n + 1);
}
}
return map;
}
function cartesianProduct(map: Map<string, TestValue[]>): TestVariables[] {
const entries = Array.from(map.entries());
let results: TestVariables[] = [{}];
for (const [variable, values] of entries) {
const next: TestVariables[] = [];
for (const vars of results) {
for (const value of values) {
next.push({ ...vars, [variable]: value });
}
}
results = next;
}
return results;
}
export async function coverageTest(dialog: Dialog): Promise<CoverageIssue[]> {
const conditions = getConditions(dialog);
const candidateMap = inferCandidateValues(conditions);
if (candidateMap.size === 0)
candidateMap.set('__dummy__', [undefined]);
const combinations = cartesianProduct(candidateMap);
const visited = new Set<string>();
const issueMap = new Map<string, CoverageIssue>();
for (const combo of combinations) {
const variables = Object.fromEntries(
Object.entries(combo).filter(([, v]) => v !== undefined)
) as DialogVariables;
const engine = new DialogEngine(dialog, { variables, actions: {} });
const stack: string[] = [dialog.startNodeId];
const seen = new Set<string>();
while (stack.length > 0) {
const nodeId = stack.pop()!;
if (seen.has(nodeId)) continue;
seen.add(nodeId);
const view = await engine.advance(nodeId);
if (view === null) continue;
visited.add(view.id);
if (view.choices !== undefined) {
if (view.choices.length === 0) {
const existing = issueMap.get(view.id);
if (existing) {
existing.combinations.push(combo);
} else {
issueMap.set(view.id, {
nodeId: view.id,
description: 'all choices eliminated',
combinations: [combo],
});
}
} else {
for (const choice of view.choices)
if (choice.nextNodeId !== undefined) stack.push(choice.nextNodeId);
}
} else if (view.nextNodeId !== undefined) {
stack.push(view.nextNodeId);
}
}
}
const unreachable = dialog.nodes
.filter(n => !visited.has(n.id))
.map(n => ({ nodeId: n.id, description: 'unreachable', combinations: [] as TestVariables[] }));
return [...issueMap.values(), ...unreachable];
}
}
export class DialogEngine {
@ -144,6 +274,8 @@ export class DialogEngine {
const choices = this.filterChoices(node.choices);
return {
id: node.id,
nextNodeId: node.nextNodeId,
speaker: node.speaker,
text: node.text,
choices,
@ -155,12 +287,11 @@ export class DialogEngine {
private filterChoices(choices?: DialogChoice[]): DialogChoice[] | undefined {
if (!choices) return undefined;
const filtered = choices.filter(choice => {
return choices.filter(choice => {
if (choice.nextNodeId === undefined) return true;
const target = this.nodeMap.get(choice.nextNodeId);
return target ? this.evaluateConditions(target.conditions ?? []) : false;
});
return filtered.length > 0 ? filtered : undefined;
}
private evaluateConditions(conditions: string[]): boolean {
@ -197,4 +328,12 @@ export class DialogEngine {
if (nextNode === null) return { done: true, value: null };
return { done: false, value: nextNode };
}
reset() {
this.currentNode = null;
}
get currentSpeaker(): string | null {
return this.currentNode?.speaker || null;
}
}

View File

@ -25,3 +25,44 @@ nodes:
text: "Safe travels."
choices:
- text: "Farewell."
nextNodeId: dead_choice
# reachable, but all choices point to guard_fight which requires hasSword — eliminated when hasSword=false
- id: dead_choice
speaker: npc
text: "One last thing — do you wish to duel my guard?"
choices:
- text: "Challenge the guard."
nextNodeId: guard_fight
- text: "Slip past."
nextNodeId: guard_fight
- id: guard_fight
speaker: npc
text: "The guard steps forward."
conditions:
- hasSword
nextNodeId: vault_gate
# vault_gate is shown only when level >= 42, but vault requires level < 40 — mutually exclusive
- id: vault_gate
speaker: npc
text: "Beyond lies an ancient vault. Only the truly powerful may enter."
conditions:
- level >= 42
choices:
- text: "Enter the vault."
nextNodeId: vault
- text: "Turn back."
nextNodeId: start
- id: vault
speaker: npc
text: "You enter the vault."
conditions:
- level < 40
# orphan — nothing points here
- id: orphan
speaker: npc
text: "You should never see this."

View File

@ -1,16 +1,10 @@
import { DialogEngine, isValidDialog } from "@common/rpg/dialog";
import { DialogEngine, Dialogs } from "@common/rpg/dialog";
import dialogYml from './dialog.yml';
import { isDialog } from "@common/rpg/types";
export default async function main() {
// console.log(dialogYml);
if (isValidDialog(dialogYml, ['hasSword'], [], ['player', 'npc'])) {
const dialog = new DialogEngine(dialogYml, {
variables: { hasSword: false },
actions: {},
});
// console.log(await dialog.advance());
for await (const node of dialog) {
console.log(node);
}
if (isDialog(dialogYml)) {
console.log(await Dialogs.coverageTest(dialogYml));
}
}