Files
nana-story/src/app/turnAdapter.spec.ts
T
Codex 1f935a3206
verify / verify (push) Has been cancelled
feat(app): complete playable branch sessions
2026-07-28 04:42:40 -04:00

111 lines
4.0 KiB
TypeScript

import { describe, expect, it } from "vitest";
import initialView from "../../fixtures/player-view/initial.json";
import type { ForkBranchRequest, PlayerView, TurnRequest } from "@contracts";
import { forkDemoBranch, submitDemoTurn } from "./turnAdapter";
function request(intent: TurnRequest["intent"], input: string): TurnRequest {
return {
storyId: initialView.storyId,
branchId: initialView.branchId,
expectedNodeId: initialView.nodeId,
actionId: `test_${intent}`,
intent,
input
};
}
describe("local turn adapter", () => {
it("preserves literal player input without inventing additional player speech", async () => {
const literalInput = "我把手电筒放在长椅上。";
const result = await submitDemoTurn(
request("speak_or_act", literalInput),
initialView as PlayerView
);
const playerBeats = result.playerView.beats.filter((beat) => beat.speaker === "你");
expect(playerBeats).toHaveLength(1);
expect(playerBeats[0]?.text).toBe(literalInput);
});
it("accepts an empty Continue intent without creating a player beat", async () => {
const result = await submitDemoTurn(request("continue", ""), initialView as PlayerView);
expect(result.committedNodeId).toBe("node_002");
expect(result.playerView.beats.some((beat) => beat.speaker === "你")).toBe(false);
expect(result.playerView.beats.at(-1)?.speaker).toBe("娜娜");
});
it("mirrors a fork without moving or deleting the source history", async () => {
const advanced = await submitDemoTurn(
request("speak_or_act", "我先去看看站台。"),
initialView as PlayerView
);
const forkRequest: ForkBranchRequest = {
storyId: advanced.playerView.storyId,
currentBranchId: advanced.playerView.branchId,
expectedCurrentNodeId: advanced.playerView.nodeId,
sourceNodeId: initialView.nodeId,
actionId: "fork_adapter_test"
};
const forked = await forkDemoBranch(forkRequest, advanced.playerView);
expect(forked.branchId).toBe("branch_fork_adapter_test");
expect(forked.playerView.nodeId).toBe(initialView.nodeId);
expect(forked.playerView.branchId).toBe(forked.branchId);
expect(forked.playerView.history).toHaveLength(1);
expect(forked.playerView.history[0]?.isCurrent).toBe(true);
expect(advanced.playerView.branchId).toBe(initialView.branchId);
expect(advanced.playerView.nodeId).toBe("node_002");
});
it("plays the promise, hidden-search clue, tunnel, and return vertical slice", async () => {
const promise = await submitDemoTurn(
{
...request("speak_or_act", "我答应你,天亮前一定回来。"),
actionId: "slice_promise"
},
initialView as PlayerView
);
expect(promise.playerView.promises[0]?.status).toBe("accepted");
const clue = await submitDemoTurn(
{
...request("continue", ""),
branchId: promise.playerView.branchId,
expectedNodeId: promise.playerView.nodeId,
actionId: "slice_clue"
},
promise.playerView
);
expect(clue.playerView.knowledge.at(-1)?.title).toBe("封锁隧道的检修门");
expect(clue.playerView.inventory.at(-1)?.name).toBe("半张旧车票");
const tunnel = await submitDemoTurn(
{
...request("continue", ""),
branchId: clue.playerView.branchId,
expectedNodeId: clue.playerView.nodeId,
actionId: "slice_tunnel"
},
clue.playerView
);
expect(tunnel.playerView.history.at(-1)?.label).toBe("进入封锁隧道");
const returned = await submitDemoTurn(
{
...request("continue", ""),
branchId: tunnel.playerView.branchId,
expectedNodeId: tunnel.playerView.nodeId,
actionId: "slice_return"
},
tunnel.playerView
);
expect(returned.playerView.promises[0]?.status).toBe("fulfilled");
expect(returned.playerView.canContinue).toBe(false);
expect(returned.playerView.beats.at(-1)?.text).toContain("你回来了");
});
});