diff --git a/src/components/dialogue/DialoguePreviewOverlay.jsx b/src/components/dialogue/DialoguePreviewOverlay.jsx index aa6175a..33f3b0d 100644 --- a/src/components/dialogue/DialoguePreviewOverlay.jsx +++ b/src/components/dialogue/DialoguePreviewOverlay.jsx @@ -76,6 +76,7 @@ export function DialoguePreviewOverlay({ edges = [], participants = [], rootDialogueId = '', + startNodeId = '', loadDialogueGraphForPreview, onStop, onNodeFocus, @@ -173,10 +174,23 @@ export function DialoguePreviewOverlay({ [getGraphRuntime] ); + // Where the preview begins in the root dialogue: the chosen node, or the Start node's first valid output. + const getRootStartRef = useCallback( + (context) => { + const wanted = String(startNodeId || '').trim(); + if (wanted) { + const graph = getGraphRuntime(safeRootDialogueId); + if (graph?.nodeMap.has(wanted)) return buildNodeRef(safeRootDialogueId, wanted); + } + return getStartPreviewNodeRef(safeRootDialogueId, context); + }, + [startNodeId, safeRootDialogueId, getGraphRuntime, getStartPreviewNodeRef] + ); + const selectedRouteNodeKeys = useMemo(() => { const route = []; const seen = new Set(); - let cursorRef = getStartPreviewNodeRef(safeRootDialogueId, scenarioContext); + let cursorRef = getRootStartRef(scenarioContext); let steps = 0; while (cursorRef && steps < MAX_PREVIEW_STEPS) { @@ -224,10 +238,10 @@ export function DialoguePreviewOverlay({ return route; }, [ - safeRootDialogueId, scenarioContext, graphCache, getStartPreviewNodeRef, + getRootStartRef, getTraversableNextNodeRefs, selectedBranches, ]); @@ -393,6 +407,8 @@ export function DialoguePreviewOverlay({ nodeRef: choiceRef, id: choiceNode.id, dialogueId: choiceRef.dialogueId, + // Action label (what the player picks) and the line the player then says. + spokenLine: String(getDialogueRowsForPreview(choiceNode)[0]?.text || '').trim(), label: choiceNode.data?.selectionTitle || choiceNode.data?.displayName || @@ -432,7 +448,12 @@ export function DialoguePreviewOverlay({ } }); - return options; + // Options that end the conversation (goodbye) go last; others keep their order. + const endsConversation = (option) => option.outcome === 'complete' || option.outcome === 'end'; + return options + .map((option, index) => ({ option, index })) + .sort((a, b) => Number(endsConversation(a.option)) - Number(endsConversation(b.option)) || a.index - b.index) + .map(({ option }) => option); }, [getGraphRuntime, buildChoiceOption] ); @@ -702,7 +723,7 @@ export function DialoguePreviewOverlay({ ); const startScenarioPreview = useCallback(() => { - const startNodeRef = getStartPreviewNodeRef(safeRootDialogueId, scenarioContext); + const startNodeRef = getRootStartRef(scenarioContext); if (!startNodeRef) { setScenarioError(t('editor.preview.invalidNoPlayablePath')); return; @@ -711,7 +732,7 @@ export function DialoguePreviewOverlay({ setScenarioReady(true); runtimeRef.current.stepCount = 0; goToNode(startNodeRef); - }, [getStartPreviewNodeRef, goToNode, safeRootDialogueId, scenarioContext, t]); + }, [getRootStartRef, goToNode, scenarioContext, t]); const preloadGraphCache = useCallback(async () => { const rootId = String(safeRootDialogueId || '').trim(); @@ -760,9 +781,9 @@ export function DialoguePreviewOverlay({ // A playback session owns timers/audio. Changing volume or callback identities // must not tear it down; StrictMode cleanup/setup must always start it afresh. const sessionCallbacksRef = useRef(null); - sessionCallbacksRef.current = { clearTimer, closePreview, goToNode, getStartPreviewNodeRef, preloadGraphCache, stopAudio }; + sessionCallbacksRef.current = { clearTimer, closePreview, goToNode, getStartPreviewNodeRef, getRootStartRef, preloadGraphCache, stopAudio }; useEffect(() => { - const { clearTimer, closePreview, goToNode, getStartPreviewNodeRef, preloadGraphCache, stopAudio } = sessionCallbacksRef.current; + const { clearTimer, closePreview, goToNode, getRootStartRef, preloadGraphCache, stopAudio } = sessionCallbacksRef.current; const runtime = runtimeRef.current; sessionRef.current += 1; if (!open) { @@ -808,10 +829,7 @@ export function DialoguePreviewOverlay({ acc[rule.key] = true; return acc; }, {}); - const initialStartNodeRef = getStartPreviewNodeRef( - safeRootDialogueId, - initialScenarioContext - ); + const initialStartNodeRef = getRootStartRef(initialScenarioContext); setScenarioRules(mergedScenarioRules); setScenarioContext(initialScenarioContext); @@ -1085,6 +1103,11 @@ export function DialoguePreviewOverlay({ >
{choice.label}
+ {choice.spokenLine && choice.spokenLine !== choice.label && ( +
+ “{choice.spokenLine}” +
+ )}
{choice.outcome === 'end' && ( diff --git a/src/i18n/locales/cs.json b/src/i18n/locales/cs.json index 8d2e9b8..e85bf7f 100644 --- a/src/i18n/locales/cs.json +++ b/src/i18n/locales/cs.json @@ -255,6 +255,9 @@ "focusNode": "Zaměřit node", "unsavedLeaveTitle": "Neuložené změny", "unsavedLeaveDescription": "Máš neuložené změny. Opuštěním stránky budou ztraceny. Pokračovat?" + }, + "preview": { + "startFromHere": "Náhled odtud" } }, "participants": { diff --git a/src/i18n/locales/de.json b/src/i18n/locales/de.json index 8b07886..c908c50 100644 --- a/src/i18n/locales/de.json +++ b/src/i18n/locales/de.json @@ -179,6 +179,9 @@ "recenterTooltip": "Gesamten Graph anzeigen", "startFocusTooltip": "Startknoten fokussieren" }, + "preview": { + "startFromHere": "Vorschau ab hier" + }, "nodes": { "start": "Start", "startDescription": "Einstiegspunkt des Dialogs", diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index ed4afcc..d146f02 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -212,6 +212,7 @@ "title": "Dialogue Preview", "start": "Preview", "startTooltip": "Start dialogue preview", + "startFromHere": "Preview from here", "stop": "Stop", "continue": "Continue", "waiting": "Waiting for next dialogue line...", diff --git a/src/i18n/locales/es.json b/src/i18n/locales/es.json index d634c85..90168b1 100644 --- a/src/i18n/locales/es.json +++ b/src/i18n/locales/es.json @@ -253,6 +253,9 @@ "focusNode": "Ir al nodo", "unsavedLeaveTitle": "Cambios sin guardar", "unsavedLeaveDescription": "Tienes cambios sin guardar. Salir los descartará. ¿Continuar?" + }, + "preview": { + "startFromHere": "Vista previa desde aquí" } }, "participants": { diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json index 1d50a74..a8850f3 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -179,6 +179,9 @@ "recenterTooltip": "Afficher tout le graphe", "startFocusTooltip": "Centrer le nœud Start" }, + "preview": { + "startFromHere": "Aperçu à partir d'ici" + }, "nodes": { "start": "Début", "startDescription": "Point d'entrée du dialogue", diff --git a/src/i18n/locales/pl.json b/src/i18n/locales/pl.json index f1da136..30af21d 100644 --- a/src/i18n/locales/pl.json +++ b/src/i18n/locales/pl.json @@ -181,6 +181,9 @@ "recenterTooltip": "Pokaż cały graf", "startFocusTooltip": "Przejdź do węzła Start" }, + "preview": { + "startFromHere": "Podgląd od tego miejsca" + }, "nodes": { "start": "Start", "startDescription": "Punkt wejścia dialogu", diff --git a/src/lib/persistence/canonicalProject.js b/src/lib/persistence/canonicalProject.js index 2c8bd9c..f15f367 100644 --- a/src/lib/persistence/canonicalProject.js +++ b/src/lib/persistence/canonicalProject.js @@ -1,11 +1,12 @@ import { AUTHORING_TABLES } from './schema'; import { allocateDialogueLocalizationSlug, + normalizeLocalizedStringEntry, normalizeProjectLocalizationConfig, prepareLocalizedNodesAndEntries, validateLocalizedEntriesForDialogue, } from '@/lib/localization/stringTable'; -import { validateCategoryTree, normalizeParticipant, validateDefinition } from '@/lib/domainIntegrity'; +import { validateCategoryTree, normalizeParticipant, validateDefinition, normalizePropertyType, normalizePropertyDefault } from '@/lib/domainIntegrity'; import { isValidRowTiming } from '@/lib/rowTiming'; export const PROJECT_FORMAT = 'mountea.project.v3'; @@ -76,6 +77,17 @@ function repairLegacyLocalization(snapshot) { locale: defaultLocale, existingEntries, }); + // prepareLocalizedNodesAndEntries stamps every entry with "now". Unchanged entries keep their timestamps, otherwise + // canonicalizing a canonical snapshot again would change its hash (and a remote revision would look corrupt). + const previousByKey = new Map(existingEntries.map((entry) => [entry.key, entry])); + prepared.entries = prepared.entries.map((entry) => { + const previous = previousByKey.get(entry.key); + const stable = previous && JSON.stringify(previous.values) === JSON.stringify(entry.values) + ? { ...entry, createdAt: previous.createdAt || entry.createdAt, modifiedAt: previous.modifiedAt || entry.modifiedAt } + : entry; + // Same shape a later pass would produce (adds keyType), so canonicalizing twice is a fixed point. + return normalizeLocalizedStringEntry(stable); + }); const preparedKeys = new Set(prepared.entries.map((entry) => entry.key)); const unusedEntries = existingEntries.filter((entry) => entry.key && !preparedKeys.has(entry.key)); snapshot.nodes = (snapshot.nodes || []).filter((node) => node.dialogueId !== dialogueId).concat(prepared.nodes); @@ -154,6 +166,17 @@ export async function canonicalizeProject(input, { allowIncompleteMedia = false snapshot[table] = (input[table] || []).map((record) => ({ ...record })); } snapshot.dialogues = snapshot.dialogues.map((row) => { const next = { ...row }; for (const field of LOCAL_FIELDS) delete next[field]; return next; }); + // Legacy property types (bool, int, float and this fork's "name") are normalized here, not only while validating, + // so a snapshot hashes the same before and after validation. + for (const table of ['decorators', 'conditions']) { + snapshot[table] = snapshot[table].map((definition) => ({ + ...definition, + properties: (Array.isArray(definition.properties) ? definition.properties : []).map((property) => { + const type = normalizePropertyType(property?.type); + return { ...property, type, defaultValue: normalizePropertyDefault(type, property?.defaultValue) }; + }), + })); + } snapshot.participants = await Promise.all(snapshot.participants.map(async (participant) => { if (!participant.thumbnail) return participant; const path = `participants.${participant.id}.thumbnail`, thumbnail = participant.thumbnail; diff --git a/src/routes/projects/$projectId/dialogue/$dialogueId/index.jsx b/src/routes/projects/$projectId/dialogue/$dialogueId/index.jsx index 71a4422..e65861a 100644 --- a/src/routes/projects/$projectId/dialogue/$dialogueId/index.jsx +++ b/src/routes/projects/$projectId/dialogue/$dialogueId/index.jsx @@ -146,6 +146,17 @@ const initialEdges = []; const noop = () => {}; +// Copy/paste clipboard for nodes. Module level so it also works between dialogues. +let nodeClipboard = null; // { projectId, nodes: [], edges: [], pasteCount: 0 } + +const cloneData = (value) => { + try { + return structuredClone(value); + } catch { + return JSON.parse(JSON.stringify(value)); + } +}; + const NodeContextMenuContext = createContext({ onDeleteNode: noop, onContextMenuAction: noop, @@ -164,7 +175,7 @@ const NodeContextMenuProvider = ({ value, children }) => { const withNodeContextMenu = (NodeComponent) => { const WrappedNode = (props) => { const { t } = useTranslation(); - const { onDeleteNode, onContextMenuAction } = useNodeContextMenu(); + const { onDeleteNode, onContextMenuAction, onPreviewFromNode } = useNodeContextMenu(); const isPlaceholder = props.type === 'placeholderNode'; const canDelete = !isPlaceholder && props.id !== START_NODE_ID; @@ -196,6 +207,17 @@ const withNodeContextMenu = (NodeComponent) => { {t('common.delete')} Del + {props.id !== START_NODE_ID && ( + { + event.preventDefault(); + onContextMenuAction?.(); + onPreviewFromNode?.(props.id); + }} + > + {t('editor.preview.startFromHere')} + + )} ); @@ -341,6 +363,7 @@ function DialogueEditorPage() { const [isCascadeDeleteOpen, setIsCascadeDeleteOpen] = useState(false); const [isEdgeCascadeDeleteOpen, setIsEdgeCascadeDeleteOpen] = useState(false); const [isPreviewOpen, setIsPreviewOpen] = useState(false); + const [previewStartNodeId, setPreviewStartNodeId] = useState(''); const [previewActiveNodeRef, setPreviewActiveNodeRef] = useState(null); const previewOriginRef = useRef(null); const graphLoadKeyRef = useRef(''); @@ -1394,7 +1417,7 @@ function DialogueEditorPage() { setPreviewActiveNodeRef(nodeRef); }, [dialogueId]); - const handleStartPreview = useCallback(() => { + const handleStartPreview = useCallback((startFromNodeId) => { if (deviceType !== 'desktop') return; const missingRequiredNodes = getMissingRequiredNodes(); @@ -1449,6 +1472,8 @@ function DialogueEditorPage() { dialogueId, nodeId: START_NODE_ID, }; + // The toolbar button passes a click event; only a string id means "start from this node". + setPreviewStartNodeId(typeof startFromNodeId === 'string' ? startFromNodeId : ''); setIsPreviewOpen(true); }, [deviceType, dialogueId, getMissingRequiredNodes, nodes, edges, t]); @@ -2003,6 +2028,110 @@ function DialogueEditorPage() { redo(); }, [redo]); + // Copy selected nodes (multi-select with Shift-drag) and the edges between them. + const handleCopyNodes = useCallback(() => { + const picked = nodes.filter( + (n) => n.type !== 'placeholderNode' && n.id !== START_NODE_ID && (n.selected || n.id === selectedNodeId) + ); + if (picked.length === 0) return false; + const ids = new Set(picked.map((n) => n.id)); + const innerEdges = edges.filter( + (e) => ids.has(e.source) && ids.has(e.target) && !e.data?.isPlaceholder + ); + nodeClipboard = { + projectId, + nodes: picked.map((n) => ({ id: n.id, type: n.type, position: { ...n.position }, data: cloneData(n.data) })), + edges: innerEdges.map((e) => ({ source: e.source, target: e.target, sourceHandle: e.sourceHandle, targetHandle: e.targetHandle })), + pasteCount: 0, + }; + return true; + }, [nodes, edges, selectedNodeId, projectId]); + + // Paste: new ids everywhere, localization keys are regenerated on save, placed with an offset. + const handlePasteNodes = useCallback(() => { + if (!nodeClipboard || nodeClipboard.nodes.length === 0) return false; + nodeClipboard.pasteCount += 1; + const offset = 40 * nodeClipboard.pasteCount; + const idMap = new Map(nodeClipboard.nodes.map((n) => [n.id, uuidv4()])); + + const newNodes = []; + const newPlaceholders = []; + const newEdges = []; + for (const source of nodeClipboard.nodes) { + const newId = idMap.get(source.id); + const data = cloneData(source.data); + delete data.displayNameKey; + delete data.selectionTitleKey; + delete data.localizationNodeToken; + if (nodeClipboard.projectId !== projectId) { + // Speakers and decorator definitions belong to the source project. + data.participant = ''; + delete data.participantId; + data.decorators = []; + if (Array.isArray(data.dialogueRows)) data.dialogueRows = data.dialogueRows.map((row) => { const next = { ...row }; delete next.participantId; return next; }); + } + if (Array.isArray(data.dialogueRows)) { + data.dialogueRows = data.dialogueRows.map((row) => { + const next = { ...row, id: uuidv4() }; + delete next.textKey; + delete next.localizationRowToken; + return next; + }); + } + if (typeof data.targetNode === 'string' && idMap.has(data.targetNode)) { + data.targetNode = idMap.get(data.targetNode); + } + newNodes.push({ + id: newId, + type: source.type, + position: { x: source.position.x + offset, y: source.position.y + offset }, + data, + selected: true, + }); + + if (getNodeDefinition(source.type)?.canHaveOutputs ?? true) { + const placeholderId = uuidv4(); + newPlaceholders.push({ + id: placeholderId, + type: 'placeholderNode', + data: { label: 'Add Node', onClick: handlePlaceholderClick, parentNodeId: newId }, + position: { x: source.position.x + offset, y: source.position.y + offset + 250 }, + }); + newEdges.push({ + id: `${newId}-${placeholderId}`, + source: newId, + target: placeholderId, + type: 'default', + style: { strokeWidth: 1, stroke: '#94a3b8', strokeDasharray: '5,5' }, + data: { isPlaceholder: true }, + }); + } + } + for (const edge of nodeClipboard.edges) { + const source = idMap.get(edge.source); + const target = idMap.get(edge.target); + newEdges.push({ + id: `${source}-${target}`, + source, + target, + sourceHandle: edge.sourceHandle, + targetHandle: edge.targetHandle, + type: 'default', + markerEnd: { type: MarkerType.ArrowClosed }, + }); + } + + const nextNodes = [...nodes.map((n) => ({ ...n, selected: false })), ...newNodes, ...newPlaceholders]; + const nextEdges = [...edges, ...newEdges]; + setNodes(nextNodes); + setEdges(nextEdges); + saveToHistory(nextNodes, nextEdges); + markUnsaved(); + setSelectedEdge(null); + setSelectedNodeId(newNodes.length === 1 ? newNodes[0].id : null); + return true; + }, [nodes, edges, projectId, setNodes, setEdges, saveToHistory, markUnsaved, handlePlaceholderClick]); + // Handle keyboard events useEffect(() => { const handleKeyDown = (event) => { @@ -2010,6 +2139,29 @@ function DialogueEditorPage() { return; } + // Copy / paste nodes (not while typing, so text copy keeps working) + if ((event.ctrlKey || event.metaKey) && !event.shiftKey && !event.altKey) { + const el = event.target; + const typing = el?.tagName === 'INPUT' || el?.tagName === 'TEXTAREA' || el?.isContentEditable; + const hasTextSelection = !!window.getSelection()?.toString(); + const key = event.key.toLowerCase(); + if (!typing && key === 'c' && !hasTextSelection) { + if (handleCopyNodes()) event.preventDefault(); + return; + } + if (!typing && key === 'v') { + if (handlePasteNodes()) event.preventDefault(); + return; + } + if (!typing && key === 'd') { + if (handleCopyNodes()) { + event.preventDefault(); + handlePasteNodes(); + } + return; + } + } + if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 's') { event.preventDefault(); handleSave(); @@ -2072,6 +2224,8 @@ function DialogueEditorPage() { handleSave, handleUndo, handleRedo, + handleCopyNodes, + handlePasteNodes, isPreviewOpen, ]); @@ -2345,8 +2499,9 @@ function DialogueEditorPage() { () => ({ onDeleteNode: deleteNodeById, onContextMenuAction: () => { lastContextMenuActionRef.current = Date.now(); }, + onPreviewFromNode: (nodeId) => handleStartPreview(String(nodeId)), }), - [deleteNodeById] + [deleteNodeById, handleStartPreview] ); if (!dialogue || !project) { @@ -2499,6 +2654,7 @@ function DialogueEditorPage() { edges={edges} participants={participants} rootDialogueId={dialogueId} + startNodeId={previewStartNodeId} loadDialogueGraphForPreview={loadDialogueGraphForPreview} onStop={handlePreviewStop} onNodeFocus={handlePreviewNodeFocus} diff --git a/tests/e2e/canonical-idempotence.spec.js b/tests/e2e/canonical-idempotence.spec.js new file mode 100644 index 0000000..2e08e1e --- /dev/null +++ b/tests/e2e/canonical-idempotence.spec.js @@ -0,0 +1,55 @@ +import { test, expect } from '@playwright/test'; +import { openModuleHarness } from './helpers/moduleHarness'; + +test.beforeEach(async ({ page }) => { await openModuleHarness(page); }); + +// A remote revision is accepted only if canonicalizing its snapshot reproduces its payload hash. +// Localized entries are stamped while canonicalizing, so the result must not depend on the clock. +test('canonicalizing a canonical snapshot keeps its hash', async ({ page }) => { + const result = await page.evaluate(async () => { + const { canonicalizeProject, hashProject } = await import('/src/lib/persistence/canonicalProject.js'); + const START = '00000000-0000-0000-0000-000000000001', stamp = '2026-01-02T03:04:05.000Z'; + const first = await canonicalizeProject({ + project: { id: 'p1', name: 'P', createdAt: stamp, modifiedAt: stamp, localization: { defaultLocale: 'en', supportedLocales: ['en'] } }, + dialogues: [{ id: 'd1', projectId: 'p1', name: 'D', createdAt: stamp, modifiedAt: stamp }], + nodes: [ + { id: START, dialogueId: 'd1', type: 'startNode', position: { x: 0, y: 0 }, data: { displayName: 'Start' } }, + { id: 'n1', dialogueId: 'd1', type: 'leadNode', position: { x: 0, y: 100 }, data: { displayName: 'Hello', dialogueRows: [{ id: 'r1', text: 'Hi there', duration: 3 }] } }, + ], + edges: [{ id: 'e1', dialogueId: 'd1', source: START, target: 'n1' }], + }); + await new Promise((resolve) => setTimeout(resolve, 25)); + const second = await canonicalizeProject(first); + const changed = structuredClone(first); + const entry = changed.localizedStrings.find((item) => item.values.en === 'Hello'); + entry.values.en = 'Hello again'; + await new Promise((resolve) => setTimeout(resolve, 25)); + const refreshed = await canonicalizeProject(changed); + return { + entries: first.localizedStrings.length, + stable: await hashProject(second) === await hashProject(first), + refreshed: refreshed.localizedStrings.find((item) => item.key === entry.key).values.en, + }; + }); + expect(result.entries).toBeGreaterThan(0); + expect(result.stable).toBe(true); + expect(result.refreshed).toBe('Hello again'); +}); + +test('legacy property types are normalized before hashing, so validation does not change the snapshot', async ({ page }) => { + const result = await page.evaluate(async () => { + const { canonicalizeProject, hashProject, validateCanonicalProject } = await import('/src/lib/persistence/canonicalProject.js'); + const stamp = '2026-01-02T03:04:05.000Z'; + const snapshot = await canonicalizeProject({ + project: { id: 'p2', name: 'P', createdAt: stamp, modifiedAt: stamp }, + decorators: [{ id: 'dec1', projectId: 'p2', name: 'Item', properties: [{ name: 'on', type: 'bool', defaultValue: 'true' }, { name: 'count', type: 'int', defaultValue: 2 }] }], + }); + const before = await hashProject(snapshot); + const problems = validateCanonicalProject(snapshot, { localization: false }); + return { types: snapshot.decorators[0].properties.map((property) => property.type), flag: snapshot.decorators[0].properties[0].defaultValue, problems, unchanged: before === await hashProject(snapshot) }; + }); + expect(result.types).toEqual(['boolean', 'number']); + expect(result.flag).toBe(true); + expect(result.problems).toEqual([]); + expect(result.unchanged).toBe(true); +}); diff --git a/tests/e2e/copypaste.spec.js b/tests/e2e/copypaste.spec.js new file mode 100644 index 0000000..c64fd36 --- /dev/null +++ b/tests/e2e/copypaste.spec.js @@ -0,0 +1,35 @@ +import { test, expect } from '@playwright/test'; +import { seedLocalState, uniqueToken, openDashboard, createProject, openDialoguesSection, createDialogue } from './helpers/appHarness'; + +test('nodes can be copied and pasted with the keyboard', async ({ page }) => { + await seedLocalState(page); + await openDashboard(page); + await createProject(page, uniqueToken('proj')); + await openDialoguesSection(page); + await createDialogue(page, uniqueToken('dlg')); + + const toolbar = page.locator('[data-tour="node-toolbar"]'); + await toolbar.locator('button.cursor-move').first().click(); + + const nodes = page.locator('.react-flow__node-leadNode'); + await expect(nodes).toHaveCount(1); + await nodes.first().click(); + + await page.keyboard.press('Control+c'); + await page.keyboard.press('Control+v'); + await expect(nodes).toHaveCount(2); + + await page.keyboard.press('Control+v'); + await expect(nodes).toHaveCount(3); + + // Duplicate (Ctrl+D) the selected node. + await page.keyboard.press('Control+d'); + await expect(nodes).toHaveCount(4); + + // Saving must not hit a localization key collision. + await page.keyboard.press('Control+s'); + await page.waitForTimeout(1500); + await expect(page.getByText(/Failed to Save/i)).toHaveCount(0); + await page.reload(); + await expect(page.locator('.react-flow__node-leadNode')).toHaveCount(4); +}); diff --git a/tests/e2e/helpers/appHarness.js b/tests/e2e/helpers/appHarness.js index 4047ab9..d85e35e 100644 --- a/tests/e2e/helpers/appHarness.js +++ b/tests/e2e/helpers/appHarness.js @@ -64,3 +64,61 @@ export async function openDialogueSettings(page) { await expect(page).toHaveURL(/#\/projects\/[^/]+\/dialogue\/[^/]+\/settings\/?$/); await expect(page.getByRole('heading', { name: /dialogue settings/i }).first()).toBeVisible(); } + +export const START_NODE_ID = '00000000-0000-0000-0000-000000000001'; + +// Builds the graph of the currently open dialogue through the app's own stores (the same path the editor +// uses), then reloads the editor. Needs the Vite dev server: modules are imported by path, so this does not +// work against the production bundle. +// `nodes`: [{ id, type, data }] (text is plain), `edges`: [[source, target]] where "start" is the Start node. +// Every node gets participant "Tester" unless it sets its own. +export async function seedGraph(page, { nodes = [], edges = [] }) { + await page.evaluate( + async ({ nodes: seedNodes, edges: seedEdges, startId }) => { + const [, projectId, dialogueId] = window.location.hash.match(/projects\/([^/]+)\/dialogue\/([^/?]+)/); + const { useCategoryStore } = await import('/src/stores/categoryStore.js'); + const { useParticipantStore } = await import('/src/stores/participantStore.js'); + const { useDialogueStore } = await import('/src/stores/dialogueStore.js'); + const category = await useCategoryStore.getState().createCategory({ projectId, name: 'Folk' }); + const participant = await useParticipantStore.getState().createParticipant({ projectId, name: 'Tester', category: category.name, categoryId: category.id }); + const toNode = (node, index) => ({ + position: { x: 300, y: 150 * (index + 1) }, + ...node, + data: { + label: node.id, + displayName: node.id, + participant: participant.name, + participantId: participant.id, + decorators: [], + hasAudio: false, + ...node.data, + }, + }); + const start = { + id: startId, + type: 'startNode', + position: { x: 0, y: 0 }, + deletable: false, + data: { label: 'Dialogue entry point', displayName: 'Start', decorators: [], dialogueRows: [], hasAudio: false }, + }; + const edges = seedEdges.map(([source, target]) => ({ + id: `${source}-${target}`, + source: source === 'start' ? startId : source, + target, + type: 'conditionEdge', + data: { conditions: { mode: 'all', rules: [] } }, + })); + await useDialogueStore.getState().saveDialogueGraph(dialogueId, [start, ...seedNodes.map(toNode)], edges, { x: 0, y: 0, zoom: 1 }, {}); + }, + { nodes, edges, startId: START_NODE_ID } + ); + await page.reload(); + await expect(page.locator('[data-tour="canvas"]')).toBeVisible(); + // The editor shows a loading overlay for a moment; wait until the seeded graph is on screen. + await expect(page.locator('.react-flow__node').first()).toBeVisible(); + await expect(page.getByText(/preparing dialogue graph/i)).toHaveCount(0, { timeout: 15000 }); +} + +export function textRow(id, text, duration = 5) { + return { id, text, duration }; +} diff --git a/tests/e2e/previewFromNode.spec.js b/tests/e2e/previewFromNode.spec.js new file mode 100644 index 0000000..f5399aa --- /dev/null +++ b/tests/e2e/previewFromNode.spec.js @@ -0,0 +1,54 @@ +import { test, expect } from '@playwright/test'; +import { + seedLocalState, + uniqueToken, + openDashboard, + createProject, + openDialoguesSection, + createDialogue, + seedGraph, + textRow, +} from './helpers/appHarness'; + +// The preview line is the only whitespace-pre-wrap paragraph; the canvas nodes show the same text too. +const previewLine = (page, text) => page.locator('p.whitespace-pre-wrap', { hasText: text }); + +async function openGraph(page) { + await seedLocalState(page); + await openDashboard(page); + await createProject(page, uniqueToken('proj')); + await openDialoguesSection(page); + await createDialogue(page, uniqueToken('dlg')); + await seedGraph(page, { + nodes: [ + { id: 'npc-one', type: 'leadNode', data: { dialogueRows: [textRow('r1', 'ALPHA first line')] } }, + { id: 'npc-two', type: 'leadNode', data: { dialogueRows: [textRow('r2', 'BRAVO second line')] } }, + ], + edges: [ + ['start', 'npc-one'], + ['npc-one', 'npc-two'], + ], + }); + await expect(page.locator('.react-flow__node-leadNode')).toHaveCount(2); +} + +test('node context menu offers "Preview from here"', async ({ page }) => { + await openGraph(page); + await page.locator('.react-flow__node[data-id="npc-two"]').click({ button: 'right' }); + await expect(page.getByRole('menuitem', { name: /preview from here/i })).toBeVisible(); +}); + +test('"Preview from here" starts the preview at the chosen node', async ({ page }) => { + await openGraph(page); + await page.locator('.react-flow__node[data-id="npc-two"]').click({ button: 'right' }); + await page.getByRole('menuitem', { name: /preview from here/i }).click(); + + await expect(previewLine(page, 'BRAVO second line')).toBeVisible(); + await expect(previewLine(page, 'ALPHA first line')).toHaveCount(0); +}); + +test('regular Preview starts at the first node after Start', async ({ page }) => { + await openGraph(page); + await page.getByRole('button', { name: /^preview$/i }).first().click(); + await expect(previewLine(page, 'ALPHA first line')).toBeVisible(); +});