Merge branch 'users' into main

Players library, circular NPC tokens, disposition types; keep main overlay/release fixes.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Ivan Fontosh
2026-08-04 08:03:22 +08:00
69 changed files with 5943 additions and 327 deletions
+3
View File
@@ -17,6 +17,7 @@ type ButtonProps = {
iconOnly?: boolean;
/** Позиция тултипа относительно кнопки. */
tooltipPlacement?: 'top' | 'bottom' | 'bottom-left';
'data-testid'?: string;
};
export function Button({
@@ -28,6 +29,7 @@ export function Button({
ariaLabel,
iconOnly = false,
tooltipPlacement = 'top',
'data-testid': testId,
}: ButtonProps) {
const btnRef = useRef<HTMLButtonElement | null>(null);
const hostRef = useRef<HTMLSpanElement | null>(null);
@@ -85,6 +87,7 @@ export function Button({
className={btnClass}
disabled={disabled}
aria-label={ariaLabel}
data-testid={testId}
onClick={disabled ? undefined : onClick}
onMouseEnter={disabled ? undefined : showTip}
onMouseLeave={disabled ? undefined : hideTip}
@@ -70,3 +70,63 @@ void test('WindowErrorBoundary component exists and catches errors', () => {
assert.ok(src.includes('componentDidCatch'));
assert.ok(src.includes('role="alert"'));
});
void test('EditorApp: Players header after File, opens modal', () => {
const src = fs.readFileSync(path.join(rendererRoot, 'editor/EditorApp.tsx'), 'utf8');
const fileIdx = src.indexOf("t('top.file')");
const playersIdx = src.indexOf('data-testid="players-header-btn"');
assert.ok(fileIdx > 0, 'File menu present');
assert.ok(playersIdx > fileIdx, 'Players button must follow File in source order');
assert.ok(src.includes('PlayersManagerModal'));
assert.ok(src.includes('setPlayersManagerOpen(true)'));
});
void test('Players modal: flat teams only, progress overlay, PlayerTokenView', () => {
const src = fs.readFileSync(path.join(rendererRoot, 'editor/PlayersModals.tsx'), 'utf8');
assert.ok(src.includes('PlayerTokenView'));
assert.ok(src.includes('data-testid="players-modal"'));
assert.ok(src.includes('data-testid="players-save-progress"'));
assert.doesNotMatch(src, /parentId|подкоманд|subteam|sub-team/i);
assert.ok(src.includes("application/x-dnd-player-id"));
});
void test('Scene NPC accordion: separate mime and PlayerTokenView markers', () => {
const scene = fs.readFileSync(path.join(rendererRoot, 'sceneEditor/SceneEditorApp.tsx'), 'utf8');
const tokenTile = fs.readFileSync(path.join(rendererRoot, 'sceneEditor/TokenTile.tsx'), 'utf8');
assert.ok(scene.includes('data-testid="scene-npc-accordion"'));
assert.ok(scene.includes("application/x-dnd-scene-npc-id"));
assert.ok(tokenTile.includes("application/x-dnd-app-token-id"));
assert.notEqual(
'application/x-dnd-scene-npc-id',
'application/x-dnd-app-token-id',
'NPC scene mime must differ from app token mime',
);
assert.ok(scene.includes('PlayerTokenView'));
assert.ok(scene.includes('persistNpcTokens([])'));
assert.doesNotMatch(scene, /SceneTokenMarker[\s\S]{0,80}npcTokens/);
});
void test('Control and Presentation use SceneNpcTokensOverlay', () => {
const control = fs.readFileSync(path.join(rendererRoot, 'control/ControlApp.tsx'), 'utf8');
const presentation = fs.readFileSync(path.join(rendererRoot, 'shared/PresentationView.tsx'), 'utf8');
assert.ok(control.includes('SceneNpcTokensOverlay'));
assert.ok(presentation.includes('SceneNpcTokensOverlay'));
assert.ok(control.includes('sceneNpcTokensSession'));
assert.ok(control.includes('ScenePlayerTokensOverlay'));
assert.ok(presentation.includes('ScenePlayerTokensOverlay'));
assert.ok(control.includes('toggle-session-players'));
});
void test('EditorApp: split Run button and launch-with-players modal', () => {
const editor = fs.readFileSync(path.join(rendererRoot, 'editor/EditorApp.tsx'), 'utf8');
assert.ok(editor.includes('splitRun'));
assert.ok(editor.includes('run-menu-btn'));
assert.ok(editor.includes('LaunchPlayersModal'));
assert.ok(editor.includes('top.runWithPlayers'));
});
void test('NpcsEditorApp: token appearance via PlayerTokenView', () => {
const src = fs.readFileSync(path.join(rendererRoot, 'npcs/NpcsEditorApp.tsx'), 'utf8');
assert.ok(src.includes('PlayerTokenView'));
assert.ok(src.includes('disposition') || src.includes('updateNpcFields'));
});