diff --git a/src/App.spec.ts b/src/App.spec.ts index 834440d..d20b2dc 100644 --- a/src/App.spec.ts +++ b/src/App.spec.ts @@ -1,25 +1,122 @@ -import { flushPromises, mount } from "@vue/test-utils"; -import { describe, expect, it } from "vitest"; +import { flushPromises, mount, type VueWrapper } from "@vue/test-utils"; +import { describe, expect, it, vi } from "vitest"; import App from "./App.vue"; +async function mountLoadedApp(): Promise { + const wrapper = mount(App, { attachTo: document.body }); + await flushPromises(); + return wrapper; +} + describe("App", () => { it("renders the canonical demo PlayerView", async () => { - const wrapper = mount(App); - await flushPromises(); + const wrapper = await mountLoadedApp(); expect(wrapper.text()).toContain("听娜娜讲故事"); expect(wrapper.text()).toContain("娜娜"); + expect(wrapper.text()).toContain("雨水沿着废弃站台的铁棚落下"); expect(wrapper.text()).toContain("你真的会在天亮前回来吗?"); expect(wrapper.get('textarea[aria-label="自由输入"]')).toBeDefined(); + wrapper.unmount(); }); - it("copies an editable suggestion into the composer", async () => { - const wrapper = mount(App); - await flushPromises(); + it("copies an editable suggestion without submitting it", async () => { + const wrapper = await mountLoadedApp(); + const startingNode = wrapper.get(".statusline span").text(); + await wrapper.get(".suggestions button").trigger("click"); const composer = wrapper.get("textarea"); expect(composer.element.value.length).toBeGreaterThan(0); + expect(wrapper.get(".statusline span").text()).toBe(startingNode); + expect(wrapper.find(".turn-status").text()).toBe(""); + expect(wrapper.find(".beat--action").exists()).toBe(false); + wrapper.unmount(); + }); + + it("submits a speak_or_act intent, clears the draft, and exposes a busy state", async () => { + const wrapper = await mountLoadedApp(); + const composer = wrapper.get('textarea[aria-label="自由输入"]'); + + await composer.setValue("我答应你,天亮前一定回来。"); + await wrapper.get("form.composer").trigger("submit"); + + expect(composer.element.value).toBe(""); + expect(wrapper.get(".send-button").text()).toBe("发送中…"); + expect(composer.attributes("disabled")).toBeDefined(); + expect(wrapper.get(".continue-button").attributes("disabled")).toBeDefined(); + + await vi.waitFor(() => { + expect(wrapper.text()).toContain("我答应你,天亮前一定回来。"); + expect(wrapper.get(".statusline span").text()).toBe("node_002"); + }); + expect(wrapper.get(".send-button").text()).toBe("发送"); + wrapper.unmount(); + }); + + it("submits Continue through the same turn path with no player speech", async () => { + const wrapper = await mountLoadedApp(); + + await wrapper.get(".continue-button").trigger("click"); + expect(wrapper.get(".continue-button").text()).toBe("继续中…"); + + await vi.waitFor(() => { + expect(wrapper.text()).toContain("一阵风越过站台"); + }); + expect(wrapper.findAll(".beat--action")).toHaveLength(0); + expect(wrapper.get(".statusline span").text()).toBe("node_002"); + wrapper.unmount(); + }); + + it("opens mutually exclusive, PlayerView-only record and relationship panels", async () => { + const wrapper = await mountLoadedApp(); + const [recordsButton, relationshipButton] = wrapper.findAll(".top-actions button"); + + await recordsButton.trigger("click"); + expect(wrapper.findAll('[role="dialog"]')).toHaveLength(1); + expect(wrapper.get("#records-panel").text()).toContain("旧手电筒"); + expect(wrapper.get("#records-panel").text()).toContain("停摆的站钟"); + expect(wrapper.get("#records-panel").text()).toContain("还没有被接受的许诺"); + + await relationshipButton.trigger("click"); + expect(wrapper.findAll('[role="dialog"]')).toHaveLength(1); + expect(wrapper.find("#records-panel").exists()).toBe(false); + + const relationshipText = wrapper.get("#relationship-panel").text(); + expect(relationshipText).toContain("好感"); + expect(relationshipText).toContain("渐暖"); + expect(relationshipText).not.toMatch(/\d/); + for (const forbidden of ["roll", "target", "hidden_facts", "world_flags", "checks"]) { + expect(wrapper.text()).not.toContain(forbidden); + } + wrapper.unmount(); + }); + + it("selects a rewind node without claiming to persist a branch", async () => { + const wrapper = await mountLoadedApp(); + const historyButton = wrapper.findAll(".top-actions button")[2]; + + await historyButton.trigger("click"); + expect(wrapper.find(".rewind-placeholder").exists()).toBe(false); + + const nodeButton = wrapper.get(".history-list button"); + await nodeButton.trigger("click"); + expect(nodeButton.attributes("aria-pressed")).toBe("true"); + expect(wrapper.get(".rewind-placeholder").text()).toContain("从这里继续(尚未接入)"); + expect(wrapper.get(".rewind-placeholder button").attributes("disabled")).toBeDefined(); + expect(wrapper.get(".rewind-placeholder").text()).toContain("不会更改当前线路"); + wrapper.unmount(); + }); + + it("closes the active panel with Escape", async () => { + const wrapper = await mountLoadedApp(); + await wrapper.findAll(".top-actions button")[0].trigger("click"); + + window.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape" })); + await wrapper.vm.$nextTick(); + + expect(wrapper.find('[role="dialog"]').exists()).toBe(false); + wrapper.unmount(); }); }); diff --git a/src/App.vue b/src/App.vue index 1823e2a..08d3a0d 100644 --- a/src/App.vue +++ b/src/App.vue @@ -1,19 +1,110 @@