Fix Story 2.3 code review findings: remove duplicate ParticipantCard.js, fix lint in ScryingPoolStrip.js

- Delete src/ui/shared/ParticipantCard.js (duplicate of boardUtils.js with conflicting implementations)
- Delete tests/unit/ui/shared/ParticipantCard.test.js (tests for deleted file)
- Add directorsBoard to global declarations in ScryingPoolStrip.js to fix lint errors

Generated by Mistral Vibe.
Co-Authored-By: Mistral Vibe <vibe@mistral.ai>
This commit is contained in:
2026-05-23 11:31:01 +02:00
parent d001659e27
commit 7918792f4e
14 changed files with 3147 additions and 13 deletions
+451
View File
@@ -0,0 +1,451 @@
// @ts-nocheck
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
// Stub foundry global for conditional base class — must NOT be present at module load time
// so the fallback class is used. The module is imported after this comment block.
// We only stub `foundry` in specific tests that need runtime foundry calls (none here).
beforeEach(() => {
vi.stubGlobal('Hooks', {
on: vi.fn(() => 99),
off: vi.fn(),
});
vi.stubGlobal('game', {
user: {
setFlag: vi.fn(),
getFlag: vi.fn(() => null),
},
});
});
afterEach(() => {
vi.unstubAllGlobals();
vi.clearAllMocks();
});
import { DirectorsBoard } from '../../../../src/ui/gm/DirectorsBoard.js';
describe('DirectorsBoard', () => {
let stateStore;
let controller;
let adapter;
let board;
beforeEach(() => {
stateStore = { getState: vi.fn(() => 'active') };
controller = { action: vi.fn(), hasPendingOp: vi.fn(() => false), getRevision: vi.fn(() => 0) };
adapter = {
users: {
get: vi.fn(() => ({ name: 'Alice', avatar: null })),
all: vi.fn(() => [{ id: 'u1' }]),
},
};
board = new DirectorsBoard(stateStore, controller, adapter);
});
describe('constructor', () => {
it('is side-effect-free: does not call Hooks.on', () => {
expect(Hooks.on).not.toHaveBeenCalled();
});
it('sets _hookId to null initially', () => {
expect(board._hookId).toBeNull();
});
it('stores stateStore, controller, adapter references', () => {
expect(board._stateStore).toBe(stateStore);
expect(board._controller).toBe(controller);
expect(board._adapter).toBe(adapter);
});
});
describe('init()', () => {
it('registers scrying-pool:stateChanged hook', () => {
board.init();
expect(Hooks.on).toHaveBeenCalledWith('scrying-pool:stateChanged', expect.any(Function));
});
it('stores the returned hook id in _hookId', () => {
board.init();
expect(board._hookId).toBe(99);
});
});
describe('teardown()', () => {
it('calls Hooks.off with the stored hook id', () => {
board.init();
board.teardown();
expect(Hooks.off).toHaveBeenCalledWith('scrying-pool:stateChanged', 99);
});
it('sets _hookId to null after teardown', () => {
board.init();
board.teardown();
expect(board._hookId).toBeNull();
});
it('is a no-op when init was not called', () => {
expect(() => board.teardown()).not.toThrow();
expect(Hooks.off).not.toHaveBeenCalled();
});
});
describe('_dispatchToggle()', () => {
it('calls controller.action with positional args (active→hidden)', () => {
stateStore.getState.mockReturnValue('active');
board._dispatchToggle('u1');
expect(controller.action).toHaveBeenCalledWith('board', 'u1', 'hidden', expect.any(String), expect.any(Number));
});
it('calls controller.action with targetState active (hidden→active)', () => {
stateStore.getState.mockReturnValue('hidden');
board._dispatchToggle('u2');
expect(controller.action).toHaveBeenCalledWith('board', 'u2', 'active', expect.any(String), expect.any(Number));
});
it('does not dispatch if userId is falsy', () => {
board._dispatchToggle(null);
board._dispatchToggle(undefined);
board._dispatchToggle('');
expect(controller.action).not.toHaveBeenCalled();
});
it('does not dispatch if controller reports pending op', () => {
controller.hasPendingOp.mockReturnValue(true);
board._dispatchToggle('u1');
expect(controller.action).not.toHaveBeenCalled();
});
it('defaults to active state when stateStore returns null', () => {
stateStore.getState.mockReturnValue(null);
board._dispatchToggle('u1');
expect(controller.action).toHaveBeenCalledWith('board', 'u1', 'hidden', expect.any(String), expect.any(Number));
});
});
describe('toggle()', () => {
it('calls render({ force: true }) when not rendered', async () => {
board._rendered = false;
const renderSpy = vi.spyOn(board, 'render').mockResolvedValue(undefined);
await board.toggle();
expect(renderSpy).toHaveBeenCalledWith({ force: true });
});
it('calls close() when rendered', async () => {
board._rendered = true;
const closeSpy = vi.spyOn(board, 'close').mockResolvedValue(undefined);
await board.toggle();
expect(closeSpy).toHaveBeenCalled();
});
});
describe('_onStateChanged()', () => {
it('calls render({ force: true }) when board is rendered', () => {
board._rendered = true;
const renderSpy = vi.spyOn(board, 'render').mockResolvedValue(undefined);
board._onStateChanged({ userId: 'u1', newState: 'hidden' });
expect(renderSpy).toHaveBeenCalledWith({ force: true });
});
it('does not call render when board is not rendered', () => {
board._rendered = false;
const renderSpy = vi.spyOn(board, 'render').mockResolvedValue(undefined);
board._onStateChanged({ userId: 'u1', newState: 'hidden' });
expect(renderSpy).not.toHaveBeenCalled();
});
});
describe('_prepareContext()', () => {
it('returns board context with participants from adapter', async () => {
const ctx = await board._prepareContext({});
expect(ctx.participants).toHaveLength(1);
expect(ctx.participants[0].userId).toBe('u1');
expect(ctx.isEmpty).toBe(false);
});
it('returns isEmpty=true when adapter has no users', async () => {
adapter.users.all.mockReturnValue([]);
const ctx = await board._prepareContext({});
expect(ctx.isEmpty).toBe(true);
});
it('returns hasUndo=false when _undoSnapshot is null', async () => {
board._undoSnapshot = null;
const ctx = await board._prepareContext({});
expect(ctx.hasUndo).toBe(false);
});
it('returns hasUndo=true when _undoSnapshot is set', async () => {
board._undoSnapshot = new Map([['u1', 'hidden']]);
const ctx = await board._prepareContext({});
expect(ctx.hasUndo).toBe(true);
});
it('returns hasRestore=false when _spotlightSnapshot is null', async () => {
board._spotlightSnapshot = null;
const ctx = await board._prepareContext({});
expect(ctx.hasRestore).toBe(false);
});
it('returns hasRestore=true when _spotlightSnapshot is set', async () => {
board._spotlightSnapshot = new Map([['u1', 'active']]);
const ctx = await board._prepareContext({});
expect(ctx.hasRestore).toBe(true);
});
});
describe('DEFAULT_OPTIONS', () => {
it('has correct id', () => {
expect(DirectorsBoard.DEFAULT_OPTIONS.id).toBe('scrying-pool-directors-board');
});
it('has classes including scrying-pool and directors-board', () => {
expect(DirectorsBoard.DEFAULT_OPTIONS.classes).toContain('scrying-pool');
expect(DirectorsBoard.DEFAULT_OPTIONS.classes).toContain('directors-board');
});
});
describe('PARTS', () => {
it('has a board part with the correct template path', () => {
expect(DirectorsBoard.PARTS.board.template).toContain('directors-board.hbs');
});
});
describe('showAll()', () => {
it('calls controller.action with active for each non-ghost user', () => {
adapter.users.all.mockReturnValue([{ id: 'u1' }, { id: 'u2' }, { id: 'u3' }]);
stateStore.getState.mockImplementation(id => id === 'u3' ? 'ghost' : 'hidden');
board.showAll();
expect(controller.action).toHaveBeenCalledTimes(2);
expect(controller.action).toHaveBeenCalledWith('board', 'u1', 'active', expect.any(String), expect.any(Number));
expect(controller.action).toHaveBeenCalledWith('board', 'u2', 'active', expect.any(String), expect.any(Number));
expect(controller.action).not.toHaveBeenCalledWith('board', 'u3', expect.anything(), expect.anything(), expect.anything());
});
it('stores pre-action snapshot in _undoSnapshot (non-ghost users only)', () => {
adapter.users.all.mockReturnValue([{ id: 'u1' }, { id: 'u2' }, { id: 'u3' }]);
stateStore.getState.mockImplementation(id => {
if (id === 'u1') return 'hidden';
if (id === 'u2') return 'active';
return 'ghost';
});
board.showAll();
expect(board._undoSnapshot).toBeInstanceOf(Map);
expect(board._undoSnapshot.get('u1')).toBe('hidden');
expect(board._undoSnapshot.get('u2')).toBe('active');
expect(board._undoSnapshot.has('u3')).toBe(false);
});
it('clears _spotlightSnapshot when called', () => {
board._spotlightSnapshot = new Map([['u1', 'active']]);
adapter.users.all.mockReturnValue([{ id: 'u1' }]);
stateStore.getState.mockReturnValue('hidden');
board.showAll();
expect(board._spotlightSnapshot).toBeNull();
});
it('skips participants with pending ops', () => {
adapter.users.all.mockReturnValue([{ id: 'u1' }, { id: 'u2' }]);
stateStore.getState.mockReturnValue('hidden');
controller.hasPendingOp.mockImplementation(id => id === 'u1');
board.showAll();
expect(controller.action).toHaveBeenCalledTimes(1);
expect(controller.action).toHaveBeenCalledWith('board', 'u2', 'active', expect.any(String), expect.any(Number));
});
it('is a no-op when all users are ghost', () => {
adapter.users.all.mockReturnValue([{ id: 'u1' }]);
stateStore.getState.mockReturnValue('ghost');
board.showAll();
expect(controller.action).not.toHaveBeenCalled();
});
});
describe('hideAll()', () => {
it('calls controller.action with hidden for each non-ghost user', () => {
adapter.users.all.mockReturnValue([{ id: 'u1' }, { id: 'u2' }]);
stateStore.getState.mockReturnValue('active');
board.hideAll();
expect(controller.action).toHaveBeenCalledTimes(2);
expect(controller.action).toHaveBeenCalledWith('board', 'u1', 'hidden', expect.any(String), expect.any(Number));
expect(controller.action).toHaveBeenCalledWith('board', 'u2', 'hidden', expect.any(String), expect.any(Number));
});
it('stores pre-action snapshot in _undoSnapshot', () => {
adapter.users.all.mockReturnValue([{ id: 'u1' }]);
stateStore.getState.mockReturnValue('active');
board.hideAll();
expect(board._undoSnapshot).toBeInstanceOf(Map);
expect(board._undoSnapshot.get('u1')).toBe('active');
});
it('clears _spotlightSnapshot when called', () => {
board._spotlightSnapshot = new Map([['u1', 'active']]);
adapter.users.all.mockReturnValue([{ id: 'u1' }]);
stateStore.getState.mockReturnValue('active');
board.hideAll();
expect(board._spotlightSnapshot).toBeNull();
});
it('excludes ghost-state participants', () => {
adapter.users.all.mockReturnValue([{ id: 'u1' }, { id: 'u2' }]);
stateStore.getState.mockImplementation(id => id === 'u2' ? 'ghost' : 'active');
board.hideAll();
expect(controller.action).toHaveBeenCalledTimes(1);
expect(controller.action).toHaveBeenCalledWith('board', 'u1', 'hidden', expect.any(String), expect.any(Number));
});
});
describe('undo()', () => {
it('restores participants to snapshot states', () => {
board._undoSnapshot = new Map([['u1', 'hidden'], ['u2', 'active']]);
stateStore.getState.mockReturnValue('active');
board.undo();
expect(controller.action).toHaveBeenCalledWith('board', 'u1', 'hidden', expect.any(String), expect.any(Number));
expect(controller.action).toHaveBeenCalledWith('board', 'u2', 'active', expect.any(String), expect.any(Number));
});
it('clears _undoSnapshot after use (single-step only)', () => {
board._undoSnapshot = new Map([['u1', 'hidden']]);
stateStore.getState.mockReturnValue('active');
board.undo();
expect(board._undoSnapshot).toBeNull();
});
it('is a no-op when _undoSnapshot is null', () => {
board._undoSnapshot = null;
board.undo();
expect(controller.action).not.toHaveBeenCalled();
});
it('second undo is unavailable after first (no-op)', () => {
board._undoSnapshot = new Map([['u1', 'hidden']]);
stateStore.getState.mockReturnValue('active');
board.undo();
board.undo();
expect(controller.action).toHaveBeenCalledTimes(1);
});
it('skips ghost-state participants during undo', () => {
board._undoSnapshot = new Map([['u1', 'active'], ['u2', 'hidden']]);
stateStore.getState.mockImplementation(id => id === 'u2' ? 'ghost' : 'active');
board.undo();
expect(controller.action).toHaveBeenCalledTimes(1);
expect(controller.action).toHaveBeenCalledWith('board', 'u1', 'active', expect.any(String), expect.any(Number));
});
it('skips participants with pending ops during undo', () => {
board._undoSnapshot = new Map([['u1', 'hidden'], ['u2', 'hidden']]);
stateStore.getState.mockReturnValue('active');
controller.hasPendingOp.mockImplementation(id => id === 'u1');
board.undo();
expect(controller.action).toHaveBeenCalledTimes(1);
expect(controller.action).toHaveBeenCalledWith('board', 'u2', 'hidden', expect.any(String), expect.any(Number));
});
});
});
describe('DirectorsBoard spotlight', () => {
let stateStore, controller, adapter, board;
beforeEach(() => {
vi.stubGlobal('Hooks', { on: vi.fn(() => 1), off: vi.fn(), once: vi.fn() });
vi.stubGlobal('game', { keybindings: { register: vi.fn() } });
stateStore = {
getState: vi.fn(),
getAll: vi.fn(() => new Map()),
};
controller = {
action: vi.fn(),
hasPendingOp: vi.fn(() => false),
getRevision: vi.fn(() => 0),
};
adapter = {
users: { all: vi.fn(() => [{ id: 'u1' }, { id: 'u2' }, { id: 'u3' }]) },
};
board = new DirectorsBoard(stateStore, controller, adapter);
board.rendered = false;
board.render = vi.fn();
});
afterEach(() => vi.unstubAllGlobals());
describe('spotlight(userId)', () => {
it('sets focusedId active, all others hidden, captures snapshot, clears undo', () => {
stateStore.getState.mockImplementation(id => ({ u1: 'hidden', u2: 'active', u3: 'active' }[id]));
board._undoSnapshot = new Map([['u1', 'hidden']]);
board.spotlight('u1');
expect(controller.action).toHaveBeenCalledWith('board', 'u1', 'active', expect.any(String), expect.any(Number));
expect(controller.action).toHaveBeenCalledWith('board', 'u2', 'hidden', expect.any(String), expect.any(Number));
expect(controller.action).toHaveBeenCalledWith('board', 'u3', 'hidden', expect.any(String), expect.any(Number));
});
it('stores pre-spotlight snapshot in _spotlightSnapshot', () => {
stateStore.getState.mockImplementation(id => id === 'u1' ? 'active' : 'hidden');
board.spotlight('u1');
expect(board._spotlightSnapshot).toBeInstanceOf(Map);
expect(board._spotlightSnapshot.size).toBe(3);
});
it('clears _undoSnapshot when spotlight is called', () => {
stateStore.getState.mockReturnValue('active');
board._undoSnapshot = new Map([['u1', 'hidden']]);
board.spotlight('u2');
expect(board._undoSnapshot).toBeNull();
});
it('excludes ghost participants from spotlight', () => {
stateStore.getState.mockImplementation(id => id === 'u3' ? 'ghost' : 'active');
board.spotlight('u1');
const calls = controller.action.mock.calls.map(c => c[1]);
expect(calls).not.toContain('u3');
});
});
describe('restoreSpotlight()', () => {
it('restores participants to pre-spotlight snapshot states', () => {
board._spotlightSnapshot = new Map([['u1', 'hidden'], ['u2', 'active'], ['u3', 'hidden']]);
stateStore.getState.mockReturnValue('active');
board.restoreSpotlight();
expect(controller.action).toHaveBeenCalledWith('board', 'u1', 'hidden', expect.any(String), expect.any(Number));
expect(controller.action).toHaveBeenCalledWith('board', 'u2', 'active', expect.any(String), expect.any(Number));
});
it('clears _spotlightSnapshot after restore', () => {
board._spotlightSnapshot = new Map([['u1', 'active']]);
stateStore.getState.mockReturnValue('active');
board.restoreSpotlight();
expect(board._spotlightSnapshot).toBeNull();
});
it('is a no-op when _spotlightSnapshot is null', () => {
board._spotlightSnapshot = null;
board.restoreSpotlight();
expect(controller.action).not.toHaveBeenCalled();
});
it('skips ghost participants during restore', () => {
board._spotlightSnapshot = new Map([['u1', 'active'], ['u2', 'hidden']]);
stateStore.getState.mockImplementation(id => id === 'u2' ? 'ghost' : 'active');
board.restoreSpotlight();
expect(controller.action).toHaveBeenCalledTimes(1);
expect(controller.action).toHaveBeenCalledWith('board', 'u1', 'active', expect.any(String), expect.any(Number));
});
});
describe('spotlightFocused()', () => {
it('is a no-op if no participant is focused', () => {
board._focusedUserId = null;
board.spotlightFocused();
expect(controller.action).not.toHaveBeenCalled();
});
it('calls spotlight() with the currently focused userId', () => {
stateStore.getState.mockReturnValue('active');
board._focusedUserId = 'u2';
const spy = vi.spyOn(board, 'spotlight');
board.spotlightFocused();
expect(spy).toHaveBeenCalledWith('u2');
});
});
});
+206
View File
@@ -0,0 +1,206 @@
// @ts-nocheck
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
// Stub Application globally before importing ScryingPoolStrip
beforeEach(() => {
vi.stubGlobal('Application', class {
static get defaultOptions() { return {}; }
constructor() { this.position = { left: 0, top: 0 }; this.rendered = false; }
render() {}
close() {}
activateListeners() {}
});
vi.stubGlobal('foundry', {
utils: {
mergeObject: (base, override) => Object.assign({}, base, override),
},
});
vi.stubGlobal('game', {
user: {
setFlag: vi.fn(),
getFlag: vi.fn(() => null),
},
});
});
afterEach(() => {
vi.unstubAllGlobals();
vi.clearAllMocks();
});
import {
LABELS,
resolveTargetState,
buildParticipantList,
ScryingPoolStrip,
} from '../../../../src/ui/gm/ScryingPoolStrip.js';
describe('LABELS', () => {
it('has HIDE_FROM_TABLE equal to exact canonical string', () => {
expect(LABELS.HIDE_FROM_TABLE).toBe('Hide from table');
});
it('has SHOW_TO_TABLE equal to exact canonical string', () => {
expect(LABELS.SHOW_TO_TABLE).toBe('Show to table');
});
it('has FIRST_TOOLTIP set', () => {
expect(LABELS.FIRST_TOOLTIP).toBe('Hide this participant from other players.');
});
it('is frozen (immutable)', () => {
expect(Object.isFrozen(LABELS)).toBe(true);
});
});
describe('resolveTargetState()', () => {
it('returns active when current state is hidden', () => {
expect(resolveTargetState('hidden')).toBe('active');
});
it('returns hidden when current state is active', () => {
expect(resolveTargetState('active')).toBe('hidden');
});
it('returns hidden when current state is self-muted', () => {
expect(resolveTargetState('self-muted')).toBe('hidden');
});
it('returns hidden when current state is cam-lost', () => {
expect(resolveTargetState('cam-lost')).toBe('hidden');
});
it('returns hidden when current state is never-connected', () => {
expect(resolveTargetState('never-connected')).toBe('hidden');
});
});
describe('buildParticipantList()', () => {
let stateStore;
let controller;
let adapter;
beforeEach(() => {
stateStore = {
getState: vi.fn(userId => userId === 'user-1' ? 'active' : 'hidden'),
};
controller = {
hasPendingOp: vi.fn(() => false),
};
adapter = {
users: {
get: vi.fn(userId => ({
id: userId,
name: `User ${userId}`,
avatar: `avatars/${userId}.png`,
})),
current: vi.fn(() => ({ id: 'user-1' })),
},
};
});
it('returns correct shape for each participant', () => {
const list = buildParticipantList(['user-1', 'user-2'], stateStore, controller, adapter);
expect(list).toHaveLength(2);
expect(list[0]).toMatchObject({
userId: 'user-1',
name: 'User user-1',
avatarSrc: 'avatars/user-1.png',
state: 'active',
stateLabel: 'Active',
hasPendingOp: false,
isCurrentUser: true,
});
});
it('returns isEmpty-compatible empty array for no userIds', () => {
const list = buildParticipantList([], stateStore, controller, adapter);
expect(list).toHaveLength(0);
});
it('sets hasPendingOp to true when controller reports pending', () => {
controller.hasPendingOp.mockReturnValue(true);
const list = buildParticipantList(['user-1'], stateStore, controller, adapter);
expect(list[0].hasPendingOp).toBe(true);
});
it('uses mystery-man.svg fallback when avatar is null', () => {
adapter.users.get.mockReturnValue({ id: 'user-1', name: 'Alice', avatar: null });
const list = buildParticipantList(['user-1'], stateStore, controller, adapter);
expect(list[0].avatarSrc).toBe('icons/svg/mystery-man.svg');
});
it('marks only the current user as isCurrentUser', () => {
const list = buildParticipantList(['user-1', 'user-2'], stateStore, controller, adapter);
expect(list[0].isCurrentUser).toBe(true);
expect(list[1].isCurrentUser).toBe(false);
});
it('correctly maps hidden state label', () => {
const list = buildParticipantList(['user-2'], stateStore, controller, adapter);
expect(list[0].stateLabel).toBe('Hidden');
});
});
describe('ScryingPoolStrip', () => {
let stateStore;
let controller;
let avTileAdapter;
let adapter;
let strip;
beforeEach(() => {
stateStore = { getState: vi.fn(() => 'active') };
controller = { action: vi.fn(), getRevision: vi.fn(() => 0), hasPendingOp: vi.fn(() => false) };
avTileAdapter = { mount: vi.fn(), unmount: vi.fn(), setStateClass: vi.fn(), disconnect: vi.fn() };
adapter = {
users: {
get: vi.fn(() => ({ id: 'u1', name: 'Alice', avatar: 'av.png' })),
all: vi.fn(() => [{ id: 'u1' }]),
current: vi.fn(() => ({ id: 'u1' })),
},
};
strip = new ScryingPoolStrip(stateStore, controller, avTileAdapter, adapter);
});
describe('defaultOptions', () => {
it('has correct id', () => {
expect(ScryingPoolStrip.defaultOptions.id).toBe('scrying-pool-strip');
});
it('has correct template path', () => {
expect(ScryingPoolStrip.defaultOptions.template).toContain('roster-strip.hbs');
});
it('is not resizable', () => {
expect(ScryingPoolStrip.defaultOptions.resizable).toBe(false);
});
it('popOut is true', () => {
expect(ScryingPoolStrip.defaultOptions.popOut).toBe(true);
});
});
describe('getData()', () => {
it('returns participants array', () => {
const data = strip.getData();
expect(Array.isArray(data.participants)).toBe(true);
});
it('returns isExpanded property', () => {
const data = strip.getData();
expect(typeof data.isExpanded).toBe('boolean');
});
it('returns isEmpty true when no participants', () => {
adapter.users.all.mockReturnValue([]);
const data = strip.getData();
expect(data.isEmpty).toBe(true);
});
it('returns isEmpty false when participants exist', () => {
const data = strip.getData();
expect(data.isEmpty).toBe(false);
});
});
});