Story 4.1: Tasks 3-6 Complete - Director's Board Integration & Settings Menu
- Task 3: Extended FoundryAdapter with user flag access methods
- Added getFlag(userId, scope, key) method
- Added setFlag(userId, scope, key, value) method
- Added getFlagModule(userId, key) convenience method
- Added setFlagModule(userId, key, value) convenience method
- Task 4: Integrated Privacy Settings with Director's Board
- Updated participant-card.hbs to show Reaction Cam badge
- Modified boardUtils.js to pass playerPrivacyManager through context
- Updated DirectorsBoard to accept and pass playerPrivacyManager
- Added CSS styles for Reaction Cam badge (SP accent color)
- Task 5: Registered PlayerPrivacyPanel in module settings
- Added settings menu registration in module.js Hooks.once('ready')
- Available to all users (restricted: false)
- Uses localized labels and hints
- Task 6: Added all localization strings
- Added SCRYING_POOL.PrivacyPanel.* strings for panel UI
- Added SCRYING_POOL.Settings.* strings for settings menu
- Updated story file with task completion status
Generated by Mistral Vibe.
Co-Authored-By: Mistral Vibe <vibe@mistral.ai>
This commit is contained in:
@@ -0,0 +1,367 @@
|
||||
/**
|
||||
* Tests for PlayerPrivacyPanel.
|
||||
* @module tests/unit/ui/player/PlayerPrivacyPanel.test
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { PlayerPrivacyPanel } from '../../../../src/ui/player/PlayerPrivacyPanel.js';
|
||||
import { createPrivacySettings } from '../../../../src/contracts/privacy-settings.js';
|
||||
|
||||
// Test helper: create a mock FoundryAdapter surface
|
||||
function createMockAdapter(overrides = {}) {
|
||||
return {
|
||||
users: {
|
||||
get: vi.fn(() => null),
|
||||
all: vi.fn(() => []),
|
||||
current: vi.fn(() => ({ id: 'test-user' })),
|
||||
...overrides.users,
|
||||
},
|
||||
notifications: {
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
...overrides.notifications,
|
||||
},
|
||||
i18n: {
|
||||
localize: vi.fn((key) => {
|
||||
// Simple mock for i18n - return known strings or the key
|
||||
const translations = {
|
||||
'SCRYING_POOL.PrivacyPanel.title': 'Player Privacy Panel',
|
||||
'SCRYING_POOL.PrivacyPanel.sectionHeader': 'Automation Opt-ins',
|
||||
'SCRYING_POOL.PrivacyPanel.sectionDescription': 'Control which automation effects can affect your camera.',
|
||||
'SCRYING_POOL.PrivacyPanel.reactionCamLabel': 'Reaction Cam',
|
||||
'SCRYING_POOL.PrivacyPanel.reactionCamDescription': 'Automatically show your camera during key moments (combat, rolls, etc.)',
|
||||
'SCRYING_POOL.PrivacyPanel.hpReactiveCamStylingLabel': 'HP-Reactive Cam Styling',
|
||||
'SCRYING_POOL.PrivacyPanel.hpReactiveCamStylingDescription': 'Apply visual styling to your camera based on your character\'s HP',
|
||||
'SCRYING_POOL.PrivacyPanel.toggleOn': 'Enabled',
|
||||
'SCRYING_POOL.PrivacyPanel.toggleOff': 'Disabled',
|
||||
'SCRYING_POOL.PrivacyPanel.readOnlyNotice': 'This player\'s privacy settings are read-only',
|
||||
'SCRYING_POOL.PrivacyPanel.savedNotification': 'Privacy settings saved',
|
||||
'SCRYING_POOL.PrivacyPanel.saveError': 'Failed to save privacy settings',
|
||||
};
|
||||
return translations[key] ?? key;
|
||||
}),
|
||||
...overrides.i18n,
|
||||
},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
// Test helper: create a mock PlayerPrivacyManager surface
|
||||
function createMockPlayerPrivacyManager(overrides = {}) {
|
||||
return {
|
||||
getSettings: vi.fn(() => createPrivacySettings()),
|
||||
setSetting: vi.fn().mockResolvedValue(undefined),
|
||||
isOptedIn: vi.fn(() => false),
|
||||
getAllSettings: vi.fn(() => new Map()),
|
||||
onChange: vi.fn(() => vi.fn()), // Returns unsubscribe function
|
||||
teardown: vi.fn(),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('PlayerPrivacyPanel', () => {
|
||||
let adapter;
|
||||
let playerPrivacyManager;
|
||||
let panel;
|
||||
const targetUserId = 'test-user-1';
|
||||
|
||||
beforeEach(() => {
|
||||
adapter = createMockAdapter();
|
||||
playerPrivacyManager = createMockPlayerPrivacyManager();
|
||||
panel = new PlayerPrivacyPanel(adapter, playerPrivacyManager, targetUserId);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('constructor', () => {
|
||||
it('should construct with valid arguments', () => {
|
||||
expect(() =>
|
||||
new PlayerPrivacyPanel(adapter, playerPrivacyManager, targetUserId)
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
it('should throw TypeError for null adapter', () => {
|
||||
expect(() =>
|
||||
new PlayerPrivacyPanel(null, playerPrivacyManager, targetUserId)
|
||||
).toThrow(TypeError);
|
||||
expect(() =>
|
||||
new PlayerPrivacyPanel(null, playerPrivacyManager, targetUserId)
|
||||
).toThrow('adapter argument is required');
|
||||
});
|
||||
|
||||
it('should throw TypeError for non-object adapter', () => {
|
||||
expect(() =>
|
||||
new PlayerPrivacyPanel('not an object', playerPrivacyManager, targetUserId)
|
||||
).toThrow(TypeError);
|
||||
expect(() =>
|
||||
new PlayerPrivacyPanel('not an object', playerPrivacyManager, targetUserId)
|
||||
).toThrow('adapter argument is required');
|
||||
});
|
||||
|
||||
it('should throw TypeError for null playerPrivacyManager', () => {
|
||||
expect(() =>
|
||||
new PlayerPrivacyPanel(adapter, null, targetUserId)
|
||||
).toThrow(TypeError);
|
||||
expect(() =>
|
||||
new PlayerPrivacyPanel(adapter, null, targetUserId)
|
||||
).toThrow('playerPrivacyManager argument is required');
|
||||
});
|
||||
|
||||
it('should throw TypeError for empty targetUserId', () => {
|
||||
expect(() =>
|
||||
new PlayerPrivacyPanel(adapter, playerPrivacyManager, '')
|
||||
).toThrow(TypeError);
|
||||
expect(() =>
|
||||
new PlayerPrivacyPanel(adapter, playerPrivacyManager, '')
|
||||
).toThrow('targetUserId must be a non-empty string');
|
||||
});
|
||||
|
||||
it('should store dependencies', () => {
|
||||
expect(panel._adapter).toBe(adapter);
|
||||
expect(panel._playerPrivacyManager).toBe(playerPrivacyManager);
|
||||
expect(panel._targetUserId).toBe(targetUserId);
|
||||
});
|
||||
});
|
||||
|
||||
describe('_prepareContext', () => {
|
||||
it('should return context with settings', async () => {
|
||||
const settings = createPrivacySettings({
|
||||
reactionCamEnabled: true,
|
||||
hpReactiveCamStylingEnabled: false,
|
||||
});
|
||||
playerPrivacyManager.getSettings.mockReturnValue(settings);
|
||||
adapter.users.current.mockReturnValue({ id: targetUserId });
|
||||
|
||||
const context = await panel._prepareContext();
|
||||
|
||||
expect(context.title).toBe('Player Privacy Panel');
|
||||
expect(context.sectionHeader).toBe('Automation Opt-ins');
|
||||
expect(context.automationEffects).toBeDefined();
|
||||
expect(context.automationEffects).toHaveLength(2);
|
||||
expect(context.isReadOnly).toBe(false);
|
||||
expect(context.isOwnUser).toBe(true);
|
||||
});
|
||||
|
||||
it('should mark as read-only when viewing another user', async () => {
|
||||
const otherUserId = 'other-user';
|
||||
panel = new PlayerPrivacyPanel(adapter, playerPrivacyManager, otherUserId);
|
||||
adapter.users.current.mockReturnValue({ id: targetUserId });
|
||||
|
||||
const context = await panel._prepareContext();
|
||||
|
||||
expect(context.isReadOnly).toBe(true);
|
||||
expect(context.isOwnUser).toBe(false);
|
||||
});
|
||||
|
||||
it('should include both automation effects', async () => {
|
||||
const context = await panel._prepareContext();
|
||||
|
||||
expect(context.automationEffects).toHaveLength(2);
|
||||
expect(context.automationEffects[0].key).toBe('reactionCam');
|
||||
expect(context.automationEffects[1].key).toBe('hpReactiveCamStyling');
|
||||
});
|
||||
|
||||
it('should reflect current settings in context', async () => {
|
||||
const settings = createPrivacySettings({
|
||||
reactionCamEnabled: true,
|
||||
hpReactiveCamStylingEnabled: true,
|
||||
});
|
||||
playerPrivacyManager.getSettings.mockReturnValue(settings);
|
||||
|
||||
const context = await panel._prepareContext();
|
||||
|
||||
expect(context.automationEffects[0].enabled).toBe(true);
|
||||
expect(context.automationEffects[1].enabled).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('_isReadOnlyMode', () => {
|
||||
it('should return false when viewing own user', () => {
|
||||
adapter.users.current.mockReturnValue({ id: targetUserId });
|
||||
expect(panel._isReadOnlyMode()).toBe(false);
|
||||
});
|
||||
|
||||
it('should return true when viewing another user', () => {
|
||||
const otherUserId = 'other-user';
|
||||
panel = new PlayerPrivacyPanel(adapter, playerPrivacyManager, otherUserId);
|
||||
adapter.users.current.mockReturnValue({ id: targetUserId });
|
||||
expect(panel._isReadOnlyMode()).toBe(true);
|
||||
});
|
||||
|
||||
it('should return true when current user is null', () => {
|
||||
adapter.users.current.mockReturnValue(null);
|
||||
expect(panel._isReadOnlyMode()).toBe(true);
|
||||
});
|
||||
|
||||
it('should return true when current user has no id', () => {
|
||||
adapter.users.current.mockReturnValue({});
|
||||
expect(panel._isReadOnlyMode()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('_onToggleChange', () => {
|
||||
beforeEach(() => {
|
||||
panel._currentSettings = createPrivacySettings();
|
||||
playerPrivacyManager.setSetting.mockClear();
|
||||
});
|
||||
|
||||
it('should revert change in read-only mode', async () => {
|
||||
const otherUserId = 'other-user';
|
||||
panel = new PlayerPrivacyPanel(adapter, playerPrivacyManager, otherUserId);
|
||||
panel._currentSettings = createPrivacySettings();
|
||||
adapter.users.current.mockReturnValue({ id: targetUserId });
|
||||
|
||||
const checkbox = {
|
||||
checked: true,
|
||||
type: 'checkbox',
|
||||
getAttribute: () => 'reactionCamEnabled',
|
||||
};
|
||||
|
||||
const event = { target: checkbox, preventDefault: vi.fn(), stopPropagation: vi.fn() };
|
||||
|
||||
await panel._onToggleChange(event);
|
||||
|
||||
// Should revert the change
|
||||
expect(checkbox.checked).toBe(false);
|
||||
expect(playerPrivacyManager.setSetting).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should update setting for own user', async () => {
|
||||
adapter.users.current.mockReturnValue({ id: targetUserId });
|
||||
playerPrivacyManager.setSetting.mockResolvedValue(undefined);
|
||||
|
||||
const checkbox = {
|
||||
checked: true,
|
||||
type: 'checkbox',
|
||||
getAttribute: () => 'reactionCamEnabled',
|
||||
};
|
||||
|
||||
const event = { target: checkbox, preventDefault: vi.fn(), stopPropagation: vi.fn() };
|
||||
|
||||
await panel._onToggleChange(event);
|
||||
|
||||
expect(playerPrivacyManager.setSetting).toHaveBeenCalledWith(
|
||||
targetUserId,
|
||||
'reactionCamEnabled',
|
||||
true
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle missing data-setting attribute', async () => {
|
||||
adapter.users.current.mockReturnValue({ id: targetUserId });
|
||||
|
||||
const checkbox = {
|
||||
checked: true,
|
||||
type: 'checkbox',
|
||||
getAttribute: () => null,
|
||||
};
|
||||
|
||||
const event = { target: checkbox, preventDefault: vi.fn(), stopPropagation: vi.fn() };
|
||||
|
||||
const consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
|
||||
await panel._onToggleChange(event);
|
||||
|
||||
expect(consoleWarnSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining('missing data-setting')
|
||||
);
|
||||
|
||||
consoleWarnSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('should handle non-checkbox target', async () => {
|
||||
const checkbox = {
|
||||
checked: true,
|
||||
type: 'radio', // Not checkbox
|
||||
getAttribute: () => 'reactionCamEnabled',
|
||||
};
|
||||
|
||||
const event = { target: checkbox, preventDefault: vi.fn(), stopPropagation: vi.fn() };
|
||||
|
||||
await panel._onToggleChange(event);
|
||||
|
||||
expect(playerPrivacyManager.setSetting).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should show success notification on successful update', async () => {
|
||||
adapter.users.current.mockReturnValue({ id: targetUserId });
|
||||
playerPrivacyManager.setSetting.mockResolvedValue(undefined);
|
||||
|
||||
const checkbox = {
|
||||
checked: true,
|
||||
type: 'checkbox',
|
||||
getAttribute: () => 'reactionCamEnabled',
|
||||
};
|
||||
|
||||
const event = { target: checkbox, preventDefault: vi.fn(), stopPropagation: vi.fn() };
|
||||
|
||||
await panel._onToggleChange(event);
|
||||
|
||||
expect(adapter.notifications.info).toHaveBeenCalledWith(
|
||||
'Privacy settings saved'
|
||||
);
|
||||
});
|
||||
|
||||
it('should revert checkbox and show error on TypeError', async () => {
|
||||
adapter.users.current.mockReturnValue({ id: targetUserId });
|
||||
playerPrivacyManager.setSetting.mockRejectedValue(
|
||||
new TypeError('Invalid key')
|
||||
);
|
||||
|
||||
const checkbox = {
|
||||
checked: true,
|
||||
type: 'checkbox',
|
||||
getAttribute: () => 'reactionCamEnabled',
|
||||
};
|
||||
|
||||
const event = { target: checkbox, preventDefault: vi.fn(), stopPropagation: vi.fn() };
|
||||
|
||||
await panel._onToggleChange(event);
|
||||
|
||||
// Should revert the change
|
||||
expect(checkbox.checked).toBe(false);
|
||||
expect(adapter.notifications.error).toHaveBeenCalledWith('Invalid key');
|
||||
});
|
||||
|
||||
it('should revert checkbox and show generic error on other errors', async () => {
|
||||
adapter.users.current.mockReturnValue({ id: targetUserId });
|
||||
playerPrivacyManager.setSetting.mockRejectedValue(
|
||||
new Error('Some other error')
|
||||
);
|
||||
|
||||
const checkbox = {
|
||||
checked: true,
|
||||
type: 'checkbox',
|
||||
getAttribute: () => 'reactionCamEnabled',
|
||||
};
|
||||
|
||||
const event = { target: checkbox, preventDefault: vi.fn(), stopPropagation: vi.fn() };
|
||||
|
||||
await panel._onToggleChange(event);
|
||||
|
||||
// Should revert the change
|
||||
expect(checkbox.checked).toBe(false);
|
||||
expect(adapter.notifications.error).toHaveBeenCalledWith(
|
||||
'Failed to save privacy settings'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('_onClose', () => {
|
||||
it('should clear cached elements', () => {
|
||||
// Set up some cached values
|
||||
panel._reactionCamToggle = document.createElement('div');
|
||||
panel._hpReactiveCamToggle = document.createElement('div');
|
||||
panel._currentSettings = createPrivacySettings();
|
||||
|
||||
panel._onClose();
|
||||
|
||||
expect(panel._reactionCamToggle).toBe(null);
|
||||
expect(panel._hpReactiveCamToggle).toBe(null);
|
||||
expect(panel._currentSettings).toBe(null);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user