this repo has no description
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

test: cover branching product behavior

+602
+602
lib/branching.test.ts
··· 1 + import { describe, expect, test } from "bun:test"; 2 + 3 + import type { SerializedBlock } from "@/lib/blocks"; 4 + import { 5 + analyzeBranchingGraph, 6 + resolveNextBlockId, 7 + resolveVisitedBlockIds, 8 + } from "@/lib/branching"; 9 + 10 + function createBlock( 11 + overrides: Partial<SerializedBlock> & Pick<SerializedBlock, "id" | "type">, 12 + ): SerializedBlock { 13 + const now = new Date("2026-04-14T00:00:00.000Z"); 14 + 15 + return { 16 + id: overrides.id, 17 + formId: "form-1", 18 + type: overrides.type, 19 + title: overrides.title ?? overrides.id, 20 + description: overrides.description ?? "", 21 + required: overrides.required ?? false, 22 + position: overrides.position ?? 0, 23 + createdAt: overrides.createdAt ?? now, 24 + updatedAt: overrides.updatedAt ?? now, 25 + config: 26 + overrides.config ?? 27 + (overrides.type === "TEXT" 28 + ? { body: "Intro" } 29 + : overrides.type === "SINGLE_CHOICE" 30 + ? { 31 + options: ["yes", "no"], 32 + branchRules: [], 33 + defaultNextBlockId: null, 34 + } 35 + : overrides.type === "MULTIPLE_CHOICE" 36 + ? { 37 + options: ["red", "blue", "green"], 38 + branchRules: [], 39 + defaultNextBlockId: null, 40 + } 41 + : overrides.type === "NUMBER" 42 + ? { 43 + placeholder: "", 44 + allowFloat: false, 45 + min: null, 46 + max: null, 47 + branchRules: [], 48 + defaultNextBlockId: null, 49 + } 50 + : overrides.type === "LINK" 51 + ? { 52 + placeholder: "", 53 + branchRules: [], 54 + defaultNextBlockId: null, 55 + } 56 + : overrides.type === "DATE" 57 + ? { 58 + branchRules: [], 59 + defaultNextBlockId: null, 60 + } 61 + : overrides.type === "AGREEMENT" 62 + ? { 63 + label: "I agree", 64 + branchRules: [], 65 + defaultNextBlockId: null, 66 + } 67 + : { 68 + placeholder: "", 69 + validationRegex: null, 70 + branchRules: [], 71 + defaultNextBlockId: null, 72 + }), 73 + }; 74 + } 75 + 76 + function getIssueCodes(blocks: SerializedBlock[]) { 77 + const analysis = analyzeBranchingGraph(blocks); 78 + 79 + return { 80 + blockers: analysis.blockers.map((issue) => issue.code), 81 + warnings: analysis.warnings.map((issue) => issue.code), 82 + }; 83 + } 84 + 85 + describe("branch-aware runner routing", () => { 86 + test("shows a conditional follow-up when the respondent answer matches a saved rule", () => { 87 + const blocks = [ 88 + createBlock({ 89 + id: "audience", 90 + type: "SINGLE_CHOICE", 91 + position: 0, 92 + config: { 93 + options: ["student", "professional"], 94 + branchRules: [ 95 + { 96 + operator: "equals", 97 + value: "student", 98 + targetBlockId: "student-follow-up", 99 + }, 100 + ], 101 + defaultNextBlockId: null, 102 + }, 103 + }), 104 + createBlock({ id: "generic-next", type: "SHORT_TEXT", position: 1 }), 105 + createBlock({ id: "student-follow-up", type: "SHORT_TEXT", position: 2 }), 106 + ]; 107 + 108 + expect( 109 + resolveNextBlockId(blocks, "audience", { audience: "student" }), 110 + ).toBe("student-follow-up"); 111 + }); 112 + 113 + test("uses the first matching rule when more than one rule could match the same answer", () => { 114 + const blocks = [ 115 + createBlock({ 116 + id: "notes", 117 + type: "SHORT_TEXT", 118 + position: 0, 119 + config: { 120 + placeholder: "", 121 + validationRegex: null, 122 + branchRules: [ 123 + { 124 + operator: "contains", 125 + value: "vip", 126 + targetBlockId: "priority-review", 127 + }, 128 + { 129 + operator: "is_not_empty", 130 + value: null, 131 + targetBlockId: "standard-review", 132 + }, 133 + ], 134 + defaultNextBlockId: null, 135 + }, 136 + }), 137 + createBlock({ id: "standard-review", type: "SHORT_TEXT", position: 1 }), 138 + createBlock({ id: "priority-review", type: "SHORT_TEXT", position: 2 }), 139 + ]; 140 + 141 + expect(resolveNextBlockId(blocks, "notes", { notes: "vip customer" })).toBe( 142 + "priority-review", 143 + ); 144 + }); 145 + 146 + test("uses the configured default route when no branch rule matches", () => { 147 + const blocks = [ 148 + createBlock({ 149 + id: "path", 150 + type: "SINGLE_CHOICE", 151 + position: 0, 152 + config: { 153 + options: ["yes", "no"], 154 + branchRules: [ 155 + { operator: "equals", value: "yes", targetBlockId: "special" }, 156 + ], 157 + defaultNextBlockId: "fallback", 158 + }, 159 + }), 160 + createBlock({ id: "fallback", type: "SHORT_TEXT", position: 1 }), 161 + createBlock({ id: "special", type: "SHORT_TEXT", position: 2 }), 162 + ]; 163 + 164 + expect(resolveNextBlockId(blocks, "path", { path: "no" })).toBe("fallback"); 165 + }); 166 + 167 + test("continues in saved order when no rule matches and no default route is configured", () => { 168 + const blocks = [ 169 + createBlock({ 170 + id: "path", 171 + type: "SINGLE_CHOICE", 172 + position: 0, 173 + config: { 174 + options: ["yes", "no"], 175 + branchRules: [ 176 + { operator: "equals", value: "yes", targetBlockId: "special" }, 177 + ], 178 + defaultNextBlockId: null, 179 + }, 180 + }), 181 + createBlock({ id: "saved-next", type: "SHORT_TEXT", position: 1 }), 182 + createBlock({ id: "special", type: "SHORT_TEXT", position: 2 }), 183 + ]; 184 + 185 + expect(resolveNextBlockId(blocks, "path", { path: "no" })).toBe( 186 + "saved-next", 187 + ); 188 + }); 189 + 190 + test("normalizes surrounding whitespace before exact text branching", () => { 191 + const blocks = [ 192 + createBlock({ 193 + id: "name", 194 + type: "SHORT_TEXT", 195 + position: 0, 196 + config: { 197 + placeholder: "", 198 + validationRegex: null, 199 + branchRules: [ 200 + { operator: "equals", value: "Ada", targetBlockId: "named-path" }, 201 + ], 202 + defaultNextBlockId: "default-path", 203 + }, 204 + }), 205 + createBlock({ id: "default-path", type: "SHORT_TEXT", position: 1 }), 206 + createBlock({ id: "named-path", type: "SHORT_TEXT", position: 2 }), 207 + ]; 208 + 209 + expect(resolveNextBlockId(blocks, "name", { name: " Ada " })).toBe( 210 + "named-path", 211 + ); 212 + }); 213 + 214 + test("treats an empty multiple-choice answer as empty for branching", () => { 215 + const blocks = [ 216 + createBlock({ 217 + id: "topics", 218 + type: "MULTIPLE_CHOICE", 219 + position: 0, 220 + config: { 221 + options: ["billing", "support", "sales"], 222 + branchRules: [ 223 + { 224 + operator: "is_empty", 225 + value: null, 226 + targetBlockId: "nothing-selected", 227 + }, 228 + ], 229 + defaultNextBlockId: "selected-something", 230 + }, 231 + }), 232 + createBlock({ 233 + id: "selected-something", 234 + type: "SHORT_TEXT", 235 + position: 1, 236 + }), 237 + createBlock({ id: "nothing-selected", type: "SHORT_TEXT", position: 2 }), 238 + ]; 239 + 240 + expect(resolveNextBlockId(blocks, "topics", { topics: [] })).toBe( 241 + "nothing-selected", 242 + ); 243 + }); 244 + 245 + test("does not take a multiple-choice branch when none of the selected answers match", () => { 246 + const blocks = [ 247 + createBlock({ 248 + id: "topics", 249 + type: "MULTIPLE_CHOICE", 250 + position: 0, 251 + config: { 252 + options: ["billing", "support", "sales"], 253 + branchRules: [ 254 + { 255 + operator: "contains_any", 256 + value: "support", 257 + targetBlockId: "support-follow-up", 258 + }, 259 + ], 260 + defaultNextBlockId: "general-follow-up", 261 + }, 262 + }), 263 + createBlock({ id: "general-follow-up", type: "SHORT_TEXT", position: 1 }), 264 + createBlock({ id: "support-follow-up", type: "SHORT_TEXT", position: 2 }), 265 + ]; 266 + 267 + expect( 268 + resolveNextBlockId(blocks, "topics", { topics: ["billing", "sales"] }), 269 + ).toBe("general-follow-up"); 270 + }); 271 + 272 + test("applies date boundary comparisons using the saved operator", () => { 273 + const blocks = [ 274 + createBlock({ 275 + id: "start-date", 276 + type: "DATE", 277 + position: 0, 278 + config: { 279 + branchRules: [ 280 + { 281 + operator: "gte", 282 + value: "2026-01-01", 283 + targetBlockId: "current-program", 284 + }, 285 + ], 286 + defaultNextBlockId: "legacy-program", 287 + }, 288 + }), 289 + createBlock({ id: "legacy-program", type: "SHORT_TEXT", position: 1 }), 290 + createBlock({ id: "current-program", type: "SHORT_TEXT", position: 2 }), 291 + ]; 292 + 293 + expect( 294 + resolveNextBlockId(blocks, "start-date", { 295 + "start-date": "2026-01-01", 296 + }), 297 + ).toBe("current-program"); 298 + }); 299 + 300 + test("fails safely to the default route when a date answer is invalid", () => { 301 + const blocks = [ 302 + createBlock({ 303 + id: "start-date", 304 + type: "DATE", 305 + position: 0, 306 + config: { 307 + branchRules: [ 308 + { 309 + operator: "gte", 310 + value: "2026-01-01", 311 + targetBlockId: "current-program", 312 + }, 313 + ], 314 + defaultNextBlockId: "legacy-program", 315 + }, 316 + }), 317 + createBlock({ id: "legacy-program", type: "SHORT_TEXT", position: 1 }), 318 + createBlock({ id: "current-program", type: "SHORT_TEXT", position: 2 }), 319 + ]; 320 + 321 + expect( 322 + resolveNextBlockId(blocks, "start-date", { 323 + "start-date": "2026-13-40", 324 + }), 325 + ).toBe("legacy-program"); 326 + }); 327 + 328 + test("branches on agreement answers using the saved exact value", () => { 329 + const blocks = [ 330 + createBlock({ 331 + id: "terms", 332 + type: "AGREEMENT", 333 + position: 0, 334 + config: { 335 + label: "I agree to the terms", 336 + branchRules: [ 337 + { operator: "equals", value: "agreed", targetBlockId: "accepted" }, 338 + ], 339 + defaultNextBlockId: "declined", 340 + }, 341 + }), 342 + createBlock({ id: "declined", type: "SHORT_TEXT", position: 1 }), 343 + createBlock({ id: "accepted", type: "SHORT_TEXT", position: 2 }), 344 + ]; 345 + 346 + expect(resolveNextBlockId(blocks, "terms", { terms: "agreed" })).toBe( 347 + "accepted", 348 + ); 349 + expect(resolveNextBlockId(blocks, "terms", { terms: "not_agreed" })).toBe( 350 + "declined", 351 + ); 352 + }); 353 + 354 + test("keeps text blocks in the visited route while branching only from question blocks", () => { 355 + const blocks = [ 356 + createBlock({ id: "intro", type: "TEXT", position: 0 }), 357 + createBlock({ 358 + id: "audience", 359 + type: "SINGLE_CHOICE", 360 + position: 1, 361 + config: { 362 + options: ["student", "professional"], 363 + branchRules: [ 364 + { 365 + operator: "equals", 366 + value: "student", 367 + targetBlockId: "student-follow-up", 368 + }, 369 + ], 370 + defaultNextBlockId: null, 371 + }, 372 + }), 373 + createBlock({ id: "generic-next", type: "SHORT_TEXT", position: 2 }), 374 + createBlock({ id: "student-follow-up", type: "SHORT_TEXT", position: 3 }), 375 + ]; 376 + 377 + expect( 378 + JSON.stringify(resolveVisitedBlockIds(blocks, { audience: "student" })), 379 + ).toBe(JSON.stringify(["intro", "audience", "student-follow-up"])); 380 + }); 381 + }); 382 + 383 + describe("visited-route history", () => { 384 + test("recalculates future visited steps when an earlier branching answer changes", () => { 385 + const blocks = [ 386 + createBlock({ 387 + id: "path", 388 + type: "SINGLE_CHOICE", 389 + position: 0, 390 + config: { 391 + options: ["standard", "priority"], 392 + branchRules: [ 393 + { 394 + operator: "equals", 395 + value: "priority", 396 + targetBlockId: "priority-review", 397 + }, 398 + ], 399 + defaultNextBlockId: "standard-review", 400 + }, 401 + }), 402 + createBlock({ 403 + id: "standard-review", 404 + type: "SINGLE_CHOICE", 405 + position: 1, 406 + config: { 407 + options: ["continue", "continue"], 408 + branchRules: [], 409 + defaultNextBlockId: "finish", 410 + }, 411 + }), 412 + createBlock({ 413 + id: "priority-review", 414 + type: "SINGLE_CHOICE", 415 + position: 2, 416 + config: { 417 + options: ["continue", "continue"], 418 + branchRules: [], 419 + defaultNextBlockId: "finish", 420 + }, 421 + }), 422 + createBlock({ id: "finish", type: "TEXT", position: 3 }), 423 + ]; 424 + 425 + const originalRoute = resolveVisitedBlockIds(blocks, { path: "priority" }); 426 + const recalculatedRoute = resolveVisitedBlockIds(blocks, { 427 + path: "standard", 428 + }); 429 + 430 + expect(JSON.stringify(originalRoute)).toBe( 431 + JSON.stringify(["path", "priority-review", "finish"]), 432 + ); 433 + expect(JSON.stringify(recalculatedRoute)).toBe( 434 + JSON.stringify(["path", "standard-review", "finish"]), 435 + ); 436 + }); 437 + }); 438 + 439 + describe("publish-time graph validation", () => { 440 + test("blocks publishing when a branch rule points to a missing block", () => { 441 + const blocks = [ 442 + createBlock({ 443 + id: "question", 444 + type: "SHORT_TEXT", 445 + position: 0, 446 + config: { 447 + placeholder: "", 448 + validationRegex: null, 449 + branchRules: [ 450 + { 451 + operator: "equals", 452 + value: "yes", 453 + targetBlockId: "missing-target", 454 + }, 455 + ], 456 + defaultNextBlockId: null, 457 + }, 458 + }), 459 + ]; 460 + 461 + expect(getIssueCodes(blocks).blockers).toContain("invalid-target"); 462 + }); 463 + 464 + test("blocks publishing when a default route points to a missing block", () => { 465 + const blocks = [ 466 + createBlock({ 467 + id: "question", 468 + type: "SHORT_TEXT", 469 + position: 0, 470 + config: { 471 + placeholder: "", 472 + validationRegex: null, 473 + branchRules: [], 474 + defaultNextBlockId: "missing-default", 475 + }, 476 + }), 477 + ]; 478 + 479 + expect(getIssueCodes(blocks).blockers).toContain("invalid-default-target"); 480 + }); 481 + 482 + test("blocks publishing when a branch points backward to an earlier block", () => { 483 + const blocks = [ 484 + createBlock({ id: "intro", type: "SHORT_TEXT", position: 0 }), 485 + createBlock({ 486 + id: "later", 487 + type: "SHORT_TEXT", 488 + position: 1, 489 + config: { 490 + placeholder: "", 491 + validationRegex: null, 492 + branchRules: [ 493 + { operator: "equals", value: "go", targetBlockId: "intro" }, 494 + ], 495 + defaultNextBlockId: null, 496 + }, 497 + }), 498 + ]; 499 + 500 + expect(getIssueCodes(blocks).blockers).toContain("backward-target"); 501 + }); 502 + 503 + test("blocks publishing when a block uses an unsupported branch operator", () => { 504 + const blocks = [ 505 + createBlock({ 506 + id: "choice", 507 + type: "SINGLE_CHOICE", 508 + position: 0, 509 + config: { 510 + options: ["yes", "no"], 511 + branchRules: [ 512 + { 513 + operator: "contains", 514 + value: "ye", 515 + targetBlockId: "follow-up", 516 + }, 517 + ], 518 + defaultNextBlockId: null, 519 + }, 520 + }), 521 + createBlock({ id: "follow-up", type: "SHORT_TEXT", position: 1 }), 522 + ]; 523 + 524 + expect(getIssueCodes(blocks).blockers).toContain("unsupported-operator"); 525 + }); 526 + 527 + test("blocks publishing when a rule value is invalid for the question type", () => { 528 + const blocks = [ 529 + createBlock({ 530 + id: "age", 531 + type: "NUMBER", 532 + position: 0, 533 + config: { 534 + placeholder: "", 535 + allowFloat: false, 536 + min: null, 537 + max: null, 538 + branchRules: [ 539 + { operator: "gte", value: "adult", targetBlockId: "allowed" }, 540 + ], 541 + defaultNextBlockId: null, 542 + }, 543 + }), 544 + createBlock({ id: "allowed", type: "SHORT_TEXT", position: 1 }), 545 + ]; 546 + 547 + expect(getIssueCodes(blocks).blockers).toContain("invalid-condition"); 548 + }); 549 + 550 + test("blocks publishing when the same branch condition is configured twice", () => { 551 + const blocks = [ 552 + createBlock({ 553 + id: "path", 554 + type: "SINGLE_CHOICE", 555 + position: 0, 556 + config: { 557 + options: ["yes", "no"], 558 + branchRules: [ 559 + { operator: "equals", value: "yes", targetBlockId: "first-yes" }, 560 + { operator: "equals", value: "yes", targetBlockId: "second-yes" }, 561 + ], 562 + defaultNextBlockId: null, 563 + }, 564 + }), 565 + createBlock({ id: "first-yes", type: "SHORT_TEXT", position: 1 }), 566 + createBlock({ id: "second-yes", type: "SHORT_TEXT", position: 2 }), 567 + ]; 568 + 569 + expect(getIssueCodes(blocks).blockers).toContain("duplicate-condition"); 570 + }); 571 + 572 + test("blocks publishing when a block becomes unreachable from the form start", () => { 573 + const blocks = [ 574 + createBlock({ 575 + id: "gate", 576 + type: "SINGLE_CHOICE", 577 + position: 0, 578 + required: true, 579 + config: { 580 + options: ["yes", "no"], 581 + branchRules: [ 582 + { operator: "equals", value: "yes", targetBlockId: "finish" }, 583 + { operator: "equals", value: "no", targetBlockId: "finish" }, 584 + ], 585 + defaultNextBlockId: null, 586 + }, 587 + }), 588 + createBlock({ id: "skipped-forever", type: "SHORT_TEXT", position: 1 }), 589 + createBlock({ id: "finish", type: "SHORT_TEXT", position: 2 }), 590 + ]; 591 + 592 + const analysis = analyzeBranchingGraph(blocks); 593 + 594 + expect( 595 + analysis.blockers.some( 596 + (issue) => 597 + issue.code === "unreachable-block" && 598 + issue.blockId === "skipped-forever", 599 + ), 600 + ).toBe(true); 601 + }); 602 + });