Fix DirectorsBoard position loading error

- Fixed TypeError: Cannot assign to read only property 'position' of object
- Changed _loadPosition() to use Object.assign() instead of direct assignment
- Added null check for this.options?.position to handle both Foundry and test environments
- Updated fallback _AppBase class to store options in constructor for test compatibility
- Added comprehensive tests for _loadPosition() method

The error occurred because in FoundryVTT v14, ApplicationV2 freezes the options object,
making direct assignment to this.options.position impossible. Using Object.assign()
merges the properties instead, which works with both frozen and unfrozen objects.

Generated by Mistral Vibe.
Co-Authored-By: Mistral Vibe <vibe@mistral.ai>
This commit is contained in:
2026-05-24 13:50:46 +02:00
parent 25dd427a59
commit 5b421d6d49
2 changed files with 134 additions and 6 deletions
+9 -2
View File
@@ -22,6 +22,9 @@ const _AppBase =
: class _FallbackApp {
static DEFAULT_OPTIONS = {};
static PARTS = {};
constructor(options = {}) {
this.options = options;
}
get rendered() { return this._rendered ?? false; }
set rendered(v) { this._rendered = v; }
get element() { return this._element ?? null; }
@@ -115,12 +118,16 @@ export class DirectorsBoard extends _AppBase {
try {
const saved = game.user?.getFlag('video-view-manager', 'directorsBoardState');
if (saved?.open === true && saved.left != null && saved.top != null) {
this.options.position = {
// Ensure options.position exists and is mutable
if (this.options?.position) {
// Use Object.assign to avoid TypeError when options is frozen (Foundry ApplicationV2)
Object.assign(this.options.position, {
left: saved.left,
top: saved.top,
width: saved.width ?? 400,
height: saved.height ?? 300,
};
});
}
}
} catch (err) {
console.error('[ScryingPool] Failed to load directors board position:', err);
+121
View File
@@ -74,6 +74,127 @@ describe('DirectorsBoard', () => {
expect(board._saveDialog).toBeNull();
expect(board._loadDialog).toBeNull();
});
it('has DEFAULT_OPTIONS with position', () => {
expect(DirectorsBoard.DEFAULT_OPTIONS.position).toEqual({
width: 400,
height: 300,
});
});
});
describe('_loadPosition()', () => {
// Helper to create a board with options for testing _loadPosition
function createBoardWithOptions(options = {}) {
return new DirectorsBoard(
stateStore,
controller,
adapter,
scenePresetManager,
undefined,
options
);
}
it('loads position from user flag when saved state has open=true', () => {
const savedState = {
open: true,
left: 100,
top: 200,
width: 500,
height: 600,
};
game.user.getFlag.mockReturnValue(savedState);
const boardWithOptions = createBoardWithOptions({ position: {} });
boardWithOptions._loadPosition();
expect(game.user.getFlag).toHaveBeenCalledWith(
'video-view-manager',
'directorsBoardState'
);
// Position should be merged into options.position (not replaced)
expect(boardWithOptions.options.position).toEqual({
left: 100,
top: 200,
width: 500,
height: 600,
});
});
it('uses defaults when saved state has no width/height', () => {
const savedState = {
open: true,
left: 100,
top: 200,
};
game.user.getFlag.mockReturnValue(savedState);
const boardWithOptions = createBoardWithOptions({ position: {} });
boardWithOptions._loadPosition();
expect(boardWithOptions.options.position).toEqual({
left: 100,
top: 200,
width: 400,
height: 300,
});
});
it('does not modify position when saved state is null', () => {
game.user.getFlag.mockReturnValue(null);
const boardWithOptions = createBoardWithOptions({ position: { width: 400, height: 300 } });
const originalPosition = boardWithOptions.options.position;
boardWithOptions._loadPosition();
expect(boardWithOptions.options.position).toBe(originalPosition);
});
it('does not modify position when open is false', () => {
game.user.getFlag.mockReturnValue({ open: false, left: 100, top: 200 });
const boardWithOptions = createBoardWithOptions({ position: { width: 400, height: 300 } });
const originalPosition = boardWithOptions.options.position;
boardWithOptions._loadPosition();
expect(boardWithOptions.options.position).toBe(originalPosition);
});
it('does not modify position when left is null', () => {
game.user.getFlag.mockReturnValue({ open: true, left: null, top: 200 });
const boardWithOptions = createBoardWithOptions({ position: { width: 400, height: 300 } });
const originalPosition = boardWithOptions.options.position;
boardWithOptions._loadPosition();
expect(boardWithOptions.options.position).toBe(originalPosition);
});
it('does not throw when options is undefined', () => {
game.user.getFlag.mockReturnValue({ open: true, left: 100, top: 200 });
// Board created without options (fallback class doesn't set options)
expect(() => board._loadPosition()).not.toThrow();
});
it('handles errors gracefully and logs to console', () => {
game.user.getFlag.mockImplementation(() => {
throw new Error('Flag read error');
});
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
const boardWithOptions = createBoardWithOptions({ position: { width: 400, height: 300 } });
const originalPosition = boardWithOptions.options.position;
boardWithOptions._loadPosition();
expect(consoleSpy).toHaveBeenCalledWith(
'[ScryingPool] Failed to load directors board position:',
expect.any(Error)
);
expect(boardWithOptions.options.position).toBe(originalPosition);
});
});
describe('init()', () => {