Files
wehub-resource-sync c48612c494
CI / E2E Tests (push) Has been cancelled
CI / Lint, Typecheck & Unit Tests (push) Has been cancelled
Docs Build / Build docs site (push) Has been cancelled
Publish @openmaic packages / Build, validate & publish (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:03:23 +08:00

75 lines
3.1 KiB
TypeScript

import type { Action } from '@/lib/types/action';
import type { Scene } from '@/lib/types/stage';
import type { SceneOutline } from '@/lib/types/generation';
import { ELEMENT_BOUND } from '@/components/edit/ActionsBar/cue-meta';
/**
* Pure content-validity checks for the editor. These surface *meaningless* edit
* states — an empty speech line, a cue pointing at nothing, a scene with no
* actions, a titleless outline — so the UI can mark them (scenes, lint-style)
* or gate a forward step (outline → generate). No UI or store dependencies.
*
* Empties are tolerated while editing; this module only *describes* them. Whom
* to block and where is the caller's policy.
*/
/** A meaningless state tied to a specific action in a scene's timeline. */
export type ActionIssue =
| { kind: 'emptySpeech'; actionId: string }
| { kind: 'unboundCue'; actionId: string }
| { kind: 'emptyDiscussion'; actionId: string };
/** A meaningless state found on a single scene (action-level issues + empty list). */
export type SceneIssue = { kind: 'emptyActions' } | ActionIssue;
/** A meaningless state found on a single outline. */
export type OutlineIssue = { kind: 'emptyTitle' };
const isBlank = (s: string | undefined): boolean => !s || s.trim() === '';
/**
* Per-action content issues, in timeline order: a blank speech line, a cue that
* points at no element, a discussion with no topic. Drives the inline marks on
* the ActionsBar clips.
*/
export function validateActions(actions: Action[]): ActionIssue[] {
const issues: ActionIssue[] = [];
for (const a of actions) {
if (a.type === 'speech' && isBlank((a as { text?: string }).text)) {
issues.push({ kind: 'emptySpeech', actionId: a.id });
} else if (ELEMENT_BOUND.has(a.type) && isBlank((a as { elementId?: string }).elementId)) {
issues.push({ kind: 'unboundCue', actionId: a.id });
} else if (a.type === 'discussion' && isBlank((a as { topic?: string }).topic)) {
issues.push({ kind: 'emptyDiscussion', actionId: a.id });
}
}
return issues;
}
/** All content-validity issues on a scene, in action order (empty list first). */
export function validateScene(scene: Scene): SceneIssue[] {
const actions = (scene.actions ?? []) as Action[];
if (actions.length === 0) return [{ kind: 'emptyActions' }];
return validateActions(actions);
}
/** Whether a scene has any content-validity issue. */
export function sceneHasIssues(scene: Scene): boolean {
return validateScene(scene).length > 0;
}
/** Content-validity issues on an outline (blank title is the only blocking one). */
export function validateOutline(outline: SceneOutline): OutlineIssue[] {
return isBlank(outline.title) ? [{ kind: 'emptyTitle' }] : [];
}
/** Whether any outline carries a blocking issue (used to gate generation). */
export function outlinesHaveBlockingIssues(outlines: SceneOutline[]): boolean {
return outlines.some((o) => validateOutline(o).length > 0);
}
/** Count of outlines with a blocking issue (drives the gate's reason text). */
export function countBlockingOutlines(outlines: SceneOutline[]): number {
return outlines.reduce((n, o) => n + (validateOutline(o).length > 0 ? 1 : 0), 0);
}