/** * 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); }); }); });