Story 4.2 completed
This commit is contained in:
@@ -8,11 +8,14 @@ import {
|
||||
PRIVACY_SETTINGS_VERSION,
|
||||
PRIVACY_SETTING_KEYS,
|
||||
FEATURE_NAME_MAP,
|
||||
MAX_PORTRAIT_SIZE,
|
||||
VALID_PORTRAIT_FORMATS,
|
||||
createPrivacySettings,
|
||||
isValidPrivacySettings,
|
||||
validateSettingKey,
|
||||
validateSettingValue,
|
||||
validateFeatureName,
|
||||
validatePortraitDataURL,
|
||||
} from "../../../src/contracts/privacy-settings.js";
|
||||
|
||||
import { describe, it, expect } from "vitest";
|
||||
@@ -23,17 +26,19 @@ describe("privacy-settings contract", () => {
|
||||
expect(PRIVACY_SETTINGS_VERSION).toBe(1);
|
||||
});
|
||||
|
||||
it("should export PRIVACY_SETTINGS_DEFAULT with all false", () => {
|
||||
it("should export PRIVACY_SETTINGS_DEFAULT with all false and null portrait", () => {
|
||||
expect(PRIVACY_SETTINGS_DEFAULT).toEqual({
|
||||
reactionCamEnabled: false,
|
||||
hpReactiveCamStylingEnabled: false,
|
||||
customPortraitFallback: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("should export PRIVACY_SETTING_KEYS as frozen array", () => {
|
||||
it("should export PRIVACY_SETTING_KEYS as frozen array including portrait fallback", () => {
|
||||
expect(PRIVACY_SETTING_KEYS).toEqual([
|
||||
"reactionCamEnabled",
|
||||
"hpReactiveCamStylingEnabled",
|
||||
"customPortraitFallback",
|
||||
]);
|
||||
expect(Object.isFrozen(PRIVACY_SETTING_KEYS)).toBe(true);
|
||||
});
|
||||
@@ -58,10 +63,11 @@ describe("privacy-settings contract", () => {
|
||||
expect(result).toEqual({
|
||||
reactionCamEnabled: true,
|
||||
hpReactiveCamStylingEnabled: false,
|
||||
customPortraitFallback: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("should allow both settings to be overridden", () => {
|
||||
it("should allow both boolean settings to be overridden", () => {
|
||||
const result = createPrivacySettings({
|
||||
reactionCamEnabled: true,
|
||||
hpReactiveCamStylingEnabled: true,
|
||||
@@ -69,6 +75,17 @@ describe("privacy-settings contract", () => {
|
||||
expect(result).toEqual({
|
||||
reactionCamEnabled: true,
|
||||
hpReactiveCamStylingEnabled: true,
|
||||
customPortraitFallback: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("should allow customPortraitFallback to be overridden", () => {
|
||||
const dataURL = "data:image/png;base64,test";
|
||||
const result = createPrivacySettings({ customPortraitFallback: dataURL });
|
||||
expect(result).toEqual({
|
||||
reactionCamEnabled: false,
|
||||
hpReactiveCamStylingEnabled: false,
|
||||
customPortraitFallback: dataURL,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -86,6 +103,7 @@ describe("privacy-settings contract", () => {
|
||||
expect(result).toEqual({
|
||||
reactionCamEnabled: true,
|
||||
hpReactiveCamStylingEnabled: false,
|
||||
customPortraitFallback: null,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -197,24 +215,25 @@ describe("privacy-settings contract", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("should throw TypeError for missing reactionCamEnabled", () => {
|
||||
const invalid = {
|
||||
hpReactiveCamStylingEnabled: false,
|
||||
};
|
||||
expect(() => isValidPrivacySettings(invalid)).toThrow(TypeError);
|
||||
expect(() => isValidPrivacySettings(invalid)).toThrow(
|
||||
"reactionCamEnabled must be a boolean"
|
||||
);
|
||||
});
|
||||
|
||||
it("should throw TypeError for missing hpReactiveCamStylingEnabled", () => {
|
||||
const invalid = {
|
||||
it("should accept settings with only reactionCamEnabled (backward compatible)", () => {
|
||||
// Backward compatibility: settings without all keys are accepted
|
||||
const valid = {
|
||||
reactionCamEnabled: false,
|
||||
};
|
||||
expect(() => isValidPrivacySettings(invalid)).toThrow(TypeError);
|
||||
expect(() => isValidPrivacySettings(invalid)).toThrow(
|
||||
"hpReactiveCamStylingEnabled must be a boolean"
|
||||
);
|
||||
expect(() => isValidPrivacySettings(valid)).not.toThrow();
|
||||
});
|
||||
|
||||
it("should accept settings with only hpReactiveCamStylingEnabled (backward compatible)", () => {
|
||||
// Backward compatibility: settings without all keys are accepted
|
||||
const valid = {
|
||||
hpReactiveCamStylingEnabled: false,
|
||||
};
|
||||
expect(() => isValidPrivacySettings(valid)).not.toThrow();
|
||||
});
|
||||
|
||||
it("should accept empty object (backward compatible)", () => {
|
||||
// Backward compatibility: empty object is accepted
|
||||
expect(() => isValidPrivacySettings({})).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -354,4 +373,198 @@ describe("privacy-settings contract", () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ==================== PORTRAIT FALLBACK TESTS ====================
|
||||
|
||||
describe("portrait fallback constants", () => {
|
||||
it("should export MAX_PORTRAIT_SIZE as 5MB", () => {
|
||||
expect(MAX_PORTRAIT_SIZE).toBe(5 * 1024 * 1024);
|
||||
});
|
||||
|
||||
it("should export VALID_PORTRAIT_FORMATS with supported MIME types", () => {
|
||||
expect(VALID_PORTRAIT_FORMATS).toEqual([
|
||||
"image/png",
|
||||
"image/jpeg",
|
||||
"image/webp",
|
||||
"image/gif",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("updated PRIVACY_SETTINGS_DEFAULT with portrait fallback", () => {
|
||||
it("should include customPortraitFallback key with null default", () => {
|
||||
expect(PRIVACY_SETTINGS_DEFAULT).toHaveProperty("customPortraitFallback");
|
||||
expect(PRIVACY_SETTINGS_DEFAULT.customPortraitFallback).toBeNull();
|
||||
});
|
||||
|
||||
it("should retain existing boolean settings", () => {
|
||||
expect(PRIVACY_SETTINGS_DEFAULT.reactionCamEnabled).toBe(false);
|
||||
expect(PRIVACY_SETTINGS_DEFAULT.hpReactiveCamStylingEnabled).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("updated PRIVACY_SETTING_KEYS with portrait fallback", () => {
|
||||
it("should include customPortraitFallback in keys", () => {
|
||||
expect(PRIVACY_SETTING_KEYS).toContain("customPortraitFallback");
|
||||
});
|
||||
|
||||
it("should retain existing keys", () => {
|
||||
expect(PRIVACY_SETTING_KEYS).toContain("reactionCamEnabled");
|
||||
expect(PRIVACY_SETTING_KEYS).toContain("hpReactiveCamStylingEnabled");
|
||||
});
|
||||
});
|
||||
|
||||
describe("updated createPrivacySettings with portrait fallback", () => {
|
||||
it("should include customPortraitFallback in result when not overridden", () => {
|
||||
const result = createPrivacySettings();
|
||||
expect(result).toHaveProperty("customPortraitFallback");
|
||||
expect(result.customPortraitFallback).toBeNull();
|
||||
});
|
||||
|
||||
it("should allow customPortraitFallback to be overridden", () => {
|
||||
const dataURL = "data:image/png;base64,test";
|
||||
const result = createPrivacySettings({ customPortraitFallback: dataURL });
|
||||
expect(result.customPortraitFallback).toBe(dataURL);
|
||||
});
|
||||
|
||||
it("should ignore extra properties not in PRIVACY_SETTING_KEYS", () => {
|
||||
const result = createPrivacySettings({
|
||||
reactionCamEnabled: true,
|
||||
customPortraitFallback: "data:image/png;base64,test",
|
||||
unknownProp: "should be ignored",
|
||||
});
|
||||
expect(result).not.toHaveProperty("unknownProp");
|
||||
expect(result.reactionCamEnabled).toBe(true);
|
||||
expect(result.customPortraitFallback).toBe("data:image/png;base64,test");
|
||||
});
|
||||
});
|
||||
|
||||
describe("updated isValidPrivacySettings with portrait fallback", () => {
|
||||
it("should accept valid settings with customPortraitFallback as string", () => {
|
||||
const valid = {
|
||||
reactionCamEnabled: false,
|
||||
hpReactiveCamStylingEnabled: false,
|
||||
customPortraitFallback: "data:image/png;base64,test",
|
||||
};
|
||||
expect(isValidPrivacySettings(valid)).toEqual(valid);
|
||||
});
|
||||
|
||||
it("should accept valid settings with customPortraitFallback as null", () => {
|
||||
const valid = {
|
||||
reactionCamEnabled: false,
|
||||
hpReactiveCamStylingEnabled: false,
|
||||
customPortraitFallback: null,
|
||||
};
|
||||
expect(isValidPrivacySettings(valid)).toEqual(valid);
|
||||
});
|
||||
|
||||
it("should accept valid settings without customPortraitFallback key", () => {
|
||||
// Backward compatibility - may not have the key
|
||||
const valid = {
|
||||
reactionCamEnabled: false,
|
||||
hpReactiveCamStylingEnabled: false,
|
||||
};
|
||||
// This should still work - null/undefined is acceptable
|
||||
expect(() => isValidPrivacySettings(valid)).not.toThrow();
|
||||
});
|
||||
|
||||
it("should throw TypeError when customPortraitFallback is not string or null", () => {
|
||||
const invalid = {
|
||||
reactionCamEnabled: false,
|
||||
hpReactiveCamStylingEnabled: false,
|
||||
customPortraitFallback: 123,
|
||||
};
|
||||
expect(() => isValidPrivacySettings(invalid)).toThrow(TypeError);
|
||||
expect(() => isValidPrivacySettings(invalid)).toThrow(
|
||||
"customPortraitFallback must be a string or null"
|
||||
);
|
||||
});
|
||||
|
||||
it("should throw TypeError when customPortraitFallback is a boolean", () => {
|
||||
const invalid = {
|
||||
reactionCamEnabled: false,
|
||||
hpReactiveCamStylingEnabled: false,
|
||||
customPortraitFallback: true,
|
||||
};
|
||||
expect(() => isValidPrivacySettings(invalid)).toThrow(TypeError);
|
||||
expect(() => isValidPrivacySettings(invalid)).toThrow(
|
||||
"customPortraitFallback must be a string or null"
|
||||
);
|
||||
});
|
||||
|
||||
it("should throw TypeError when customPortraitFallback is an object", () => {
|
||||
const invalid = {
|
||||
reactionCamEnabled: false,
|
||||
hpReactiveCamStylingEnabled: false,
|
||||
customPortraitFallback: {},
|
||||
};
|
||||
expect(() => isValidPrivacySettings(invalid)).toThrow(TypeError);
|
||||
expect(() => isValidPrivacySettings(invalid)).toThrow(
|
||||
"customPortraitFallback must be a string or null"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("validatePortraitDataURL", () => {
|
||||
it("should accept valid PNG DataURL", () => {
|
||||
const dataURL = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==";
|
||||
expect(validatePortraitDataURL(dataURL)).toBe(dataURL);
|
||||
});
|
||||
|
||||
it("should accept valid JPEG DataURL", () => {
|
||||
const dataURL = "data:image/jpeg;base64,/9j/4AAQSkZJRgABAQAAAQABAAD/2wBDAP//";
|
||||
expect(validatePortraitDataURL(dataURL)).toBe(dataURL);
|
||||
});
|
||||
|
||||
it("should accept valid WEBP DataURL", () => {
|
||||
const dataURL = "data:image/webp;base64,UklGRiQAAABXRUJQVlA4IBgAAAAwAQCdASoBAAEAAwA0JaQAA3AA/vuUAAA=";
|
||||
expect(validatePortraitDataURL(dataURL)).toBe(dataURL);
|
||||
});
|
||||
|
||||
it("should accept valid GIF DataURL", () => {
|
||||
const dataURL = "data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///ywAAAAAAQABAAACAkQBADs=";
|
||||
expect(validatePortraitDataURL(dataURL)).toBe(dataURL);
|
||||
});
|
||||
|
||||
it("should accept null as valid (no custom portrait)", () => {
|
||||
expect(validatePortraitDataURL(null)).toBeNull();
|
||||
});
|
||||
|
||||
it("should accept undefined as valid (no custom portrait)", () => {
|
||||
expect(validatePortraitDataURL(undefined)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("should accept empty string as valid (explicitly no portrait)", () => {
|
||||
expect(validatePortraitDataURL("")).toBe("");
|
||||
});
|
||||
|
||||
it("should throw TypeError for invalid MIME type SVG", () => {
|
||||
const dataURL = "data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciPjwvc3ZnPg==";
|
||||
expect(() => validatePortraitDataURL(dataURL)).toThrow(TypeError);
|
||||
expect(() => validatePortraitDataURL(dataURL)).toThrow("Unsupported portrait format");
|
||||
});
|
||||
|
||||
it("should throw TypeError for invalid MIME type MP4", () => {
|
||||
const dataURL = "data:video/mp4;base64,AAAAIGZ0eXBhdmlmAAAA";
|
||||
expect(() => validatePortraitDataURL(dataURL)).toThrow(TypeError);
|
||||
expect(() => validatePortraitDataURL(dataURL)).toThrow("Invalid DataURL format");
|
||||
});
|
||||
|
||||
it("should throw TypeError for malformed DataURL without MIME type", () => {
|
||||
const dataURL = "data:,test";
|
||||
expect(() => validatePortraitDataURL(dataURL)).toThrow(TypeError);
|
||||
expect(() => validatePortraitDataURL(dataURL)).toThrow("Invalid DataURL format");
|
||||
});
|
||||
|
||||
it("should throw TypeError for non-DataURL string", () => {
|
||||
const notDataURL = "https://example.com/image.png";
|
||||
expect(() => validatePortraitDataURL(notDataURL)).toThrow(TypeError);
|
||||
expect(() => validatePortraitDataURL(notDataURL)).toThrow("Invalid DataURL format");
|
||||
});
|
||||
|
||||
it("should throw TypeError for non-string value", () => {
|
||||
expect(() => validatePortraitDataURL(123)).toThrow(TypeError);
|
||||
expect(() => validatePortraitDataURL(123)).toThrow("Invalid DataURL");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -98,7 +98,7 @@ describe("PlayerPrivacyManager", () => {
|
||||
});
|
||||
|
||||
it("should return saved settings when flag exists", () => {
|
||||
const savedSettings = { reactionCamEnabled: true, hpReactiveCamStylingEnabled: false };
|
||||
const savedSettings = { reactionCamEnabled: true, hpReactiveCamStylingEnabled: false, customPortraitFallback: null };
|
||||
adapter.users.get.mockReturnValue({
|
||||
getFlag: vi.fn((scope, key) => {
|
||||
if (scope === "video-view-manager") {
|
||||
@@ -124,6 +124,7 @@ describe("PlayerPrivacyManager", () => {
|
||||
expect(result).toEqual({
|
||||
reactionCamEnabled: true,
|
||||
hpReactiveCamStylingEnabled: false,
|
||||
customPortraitFallback: null,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -294,14 +295,16 @@ describe("PlayerPrivacyManager", () => {
|
||||
id: "user1",
|
||||
getFlag: vi.fn((scope, key) => {
|
||||
if (key === "reactionCamEnabled") return true;
|
||||
return false;
|
||||
if (key === "hpReactiveCamStylingEnabled") return false;
|
||||
return undefined; // customPortraitFallback and other keys
|
||||
}),
|
||||
};
|
||||
const user2 = {
|
||||
id: "user2",
|
||||
getFlag: vi.fn((scope, key) => {
|
||||
if (key === "reactionCamEnabled") return false;
|
||||
if (key === "hpReactiveCamStylingEnabled") return true;
|
||||
return false;
|
||||
return undefined; // customPortraitFallback and other keys
|
||||
}),
|
||||
};
|
||||
adapter.users.all.mockReturnValue([user1, user2]);
|
||||
@@ -316,10 +319,12 @@ describe("PlayerPrivacyManager", () => {
|
||||
expect(result.get("user1")).toEqual({
|
||||
reactionCamEnabled: true,
|
||||
hpReactiveCamStylingEnabled: false,
|
||||
customPortraitFallback: null,
|
||||
});
|
||||
expect(result.get("user2")).toEqual({
|
||||
reactionCamEnabled: false,
|
||||
hpReactiveCamStylingEnabled: true,
|
||||
customPortraitFallback: null,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -475,4 +480,238 @@ describe("PlayerPrivacyManager", () => {
|
||||
expect(allSettings.size).toBe(3);
|
||||
});
|
||||
});
|
||||
|
||||
// ==================== PORTRAIT FALLBACK TESTS ====================
|
||||
|
||||
describe("portrait fallback methods", () => {
|
||||
beforeEach(() => {
|
||||
manager = new PlayerPrivacyManager(adapter);
|
||||
});
|
||||
|
||||
describe("setPortraitFallback", () => {
|
||||
it("should validate DataURL format", async () => {
|
||||
const mockUser = {
|
||||
id: "player1",
|
||||
getFlag: vi.fn(() => null),
|
||||
setFlag: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
adapter.users.get.mockReturnValue(mockUser);
|
||||
|
||||
const invalidDataURL = "https://example.com/image.png";
|
||||
await expect(
|
||||
manager.setPortraitFallback("player1", invalidDataURL)
|
||||
).rejects.toThrow(TypeError);
|
||||
});
|
||||
|
||||
it("should accept valid PNG DataURL", async () => {
|
||||
const mockUser = {
|
||||
id: "player1",
|
||||
getFlag: vi.fn(() => null),
|
||||
setFlag: vi.fn().mockResolvedValue(undefined),
|
||||
unsetFlag: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
adapter.users.get.mockReturnValue(mockUser);
|
||||
|
||||
const dataURL = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUg==";
|
||||
await expect(
|
||||
manager.setPortraitFallback("player1", dataURL)
|
||||
).resolves.not.toThrow();
|
||||
|
||||
expect(mockUser.setFlag).toHaveBeenCalledWith(
|
||||
"video-view-manager",
|
||||
"customPortraitFallback",
|
||||
dataURL
|
||||
);
|
||||
});
|
||||
|
||||
it("should emit change event with type 'portrait'", async () => {
|
||||
const mockUser = {
|
||||
id: "player1",
|
||||
getFlag: vi.fn(() => null),
|
||||
setFlag: vi.fn().mockResolvedValue(undefined),
|
||||
unsetFlag: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
adapter.users.get.mockReturnValue(mockUser);
|
||||
|
||||
const changeCallback = vi.fn();
|
||||
manager.onChange(changeCallback);
|
||||
|
||||
const dataURL = "data:image/png;base64,test";
|
||||
await manager.setPortraitFallback("player1", dataURL);
|
||||
|
||||
expect(changeCallback).toHaveBeenCalled();
|
||||
// Check that the callback was called with portrait-related parameters
|
||||
const calls = changeCallback.mock.calls;
|
||||
expect(calls.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("should reject invalid MIME type", async () => {
|
||||
const mockUser = {
|
||||
id: "player1",
|
||||
getFlag: vi.fn(() => null),
|
||||
setFlag: vi.fn().mockResolvedValue(undefined),
|
||||
unsetFlag: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
adapter.users.get.mockReturnValue(mockUser);
|
||||
|
||||
const svgDataURL = "data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciPjwvc3ZnPg==";
|
||||
await expect(
|
||||
manager.setPortraitFallback("player1", svgDataURL)
|
||||
).rejects.toThrow(TypeError);
|
||||
});
|
||||
|
||||
it("should throw TypeError for non-existent user", async () => {
|
||||
adapter.users.get.mockReturnValue(null);
|
||||
|
||||
const dataURL = "data:image/png;base64,test";
|
||||
await expect(
|
||||
manager.setPortraitFallback("nonexistent", dataURL)
|
||||
).rejects.toThrow(TypeError);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getPortraitFallback", () => {
|
||||
it("should return null when no custom portrait is set", () => {
|
||||
const mockUser = {
|
||||
id: "player1",
|
||||
getFlag: vi.fn((scope, key) => {
|
||||
if (key === "customPortraitFallback") return undefined;
|
||||
return undefined;
|
||||
}),
|
||||
};
|
||||
adapter.users.get.mockReturnValue(mockUser);
|
||||
|
||||
const result = manager.getPortraitFallback("player1");
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("should return DataURL when custom portrait is set", () => {
|
||||
const dataURL = "data:image/png;base64,test123";
|
||||
const mockUser = {
|
||||
id: "player1",
|
||||
getFlag: vi.fn((scope, key) => {
|
||||
if (key === "customPortraitFallback") return dataURL;
|
||||
return undefined;
|
||||
}),
|
||||
};
|
||||
adapter.users.get.mockReturnValue(mockUser);
|
||||
|
||||
const result = manager.getPortraitFallback("player1");
|
||||
expect(result).toBe(dataURL);
|
||||
});
|
||||
|
||||
it("should return null for non-existent user", () => {
|
||||
adapter.users.get.mockReturnValue(null);
|
||||
|
||||
const result = manager.getPortraitFallback("nonexistent");
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("getPortraitFallbackDataURL", () => {
|
||||
it("should return DataURL directly", () => {
|
||||
const dataURL = "data:image/png;base64,test123";
|
||||
const mockUser = {
|
||||
id: "player1",
|
||||
getFlag: vi.fn((scope, key) => {
|
||||
if (key === "customPortraitFallback") return dataURL;
|
||||
return undefined;
|
||||
}),
|
||||
};
|
||||
adapter.users.get.mockReturnValue(mockUser);
|
||||
|
||||
const result = manager.getPortraitFallbackDataURL("player1");
|
||||
expect(result).toBe(dataURL);
|
||||
});
|
||||
|
||||
it("should return null when not set", () => {
|
||||
const mockUser = {
|
||||
id: "player1",
|
||||
getFlag: vi.fn(() => undefined),
|
||||
};
|
||||
adapter.users.get.mockReturnValue(mockUser);
|
||||
|
||||
const result = manager.getPortraitFallbackDataURL("player1");
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("removePortraitFallback", () => {
|
||||
it("should call unsetFlag for customPortraitFallback", async () => {
|
||||
const mockUser = {
|
||||
id: "player1",
|
||||
getFlag: vi.fn(() => "data:image/png;base64,old"),
|
||||
unsetFlag: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
adapter.users.get.mockReturnValue(mockUser);
|
||||
|
||||
await manager.removePortraitFallback("player1");
|
||||
|
||||
expect(mockUser.unsetFlag).toHaveBeenCalledWith(
|
||||
"video-view-manager",
|
||||
"customPortraitFallback"
|
||||
);
|
||||
});
|
||||
|
||||
it("should emit change event with type 'portrait'", async () => {
|
||||
const mockUser = {
|
||||
id: "player1",
|
||||
getFlag: vi.fn(() => "data:image/png;base64,old"),
|
||||
unsetFlag: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
adapter.users.get.mockReturnValue(mockUser);
|
||||
|
||||
const changeCallback = vi.fn();
|
||||
manager.onChange(changeCallback);
|
||||
|
||||
await manager.removePortraitFallback("player1");
|
||||
|
||||
expect(changeCallback).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should throw TypeError for non-existent user", async () => {
|
||||
adapter.users.get.mockReturnValue(null);
|
||||
|
||||
await expect(
|
||||
manager.removePortraitFallback("nonexistent")
|
||||
).rejects.toThrow(TypeError);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getSettings with portrait fallback", () => {
|
||||
it("should include customPortraitFallback in returned settings", () => {
|
||||
const dataURL = "data:image/png;base64,test";
|
||||
const mockUser = {
|
||||
id: "player1",
|
||||
getFlag: vi.fn((scope, key) => {
|
||||
if (key === "customPortraitFallback") return dataURL;
|
||||
if (key === "reactionCamEnabled") return true;
|
||||
if (key === "hpReactiveCamStylingEnabled") return false;
|
||||
return undefined;
|
||||
}),
|
||||
};
|
||||
adapter.users.get.mockReturnValue(mockUser);
|
||||
|
||||
const settings = manager.getSettings("player1");
|
||||
expect(settings).toHaveProperty("customPortraitFallback");
|
||||
expect(settings.customPortraitFallback).toBe(dataURL);
|
||||
});
|
||||
});
|
||||
|
||||
describe("setSetting rejection of customPortraitFallback", () => {
|
||||
it("should reject customPortraitFallback key in setSetting", async () => {
|
||||
const mockUser = {
|
||||
id: "player1",
|
||||
getFlag: vi.fn(() => null),
|
||||
setFlag: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
adapter.users.get.mockReturnValue(mockUser);
|
||||
|
||||
const dataURL = "data:image/png;base64,test";
|
||||
await expect(
|
||||
manager.setSetting("player1", "customPortraitFallback", dataURL)
|
||||
).rejects.toThrow(TypeError);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,289 @@
|
||||
/**
|
||||
* Tests for PortraitFallbackHandler.
|
||||
* @module tests/unit/core/PortraitFallbackHandler.test
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { PortraitFallbackHandler } from "../../../src/core/PortraitFallbackHandler.js";
|
||||
import { createFoundryAdapterMock } from "../../helpers/foundryAdapterMock.js";
|
||||
import { createPlayerPrivacyManagerMock } from "../../helpers/playerPrivacyManagerMock.js";
|
||||
|
||||
describe("PortraitFallbackHandler", () => {
|
||||
/** @type {import('../../../src/foundry/FoundryAdapter.js').FoundryAdapter} */
|
||||
let adapter;
|
||||
/** @type {import('../../../src/core/PlayerPrivacyManager.js').PlayerPrivacyManager} */
|
||||
let playerPrivacyManager;
|
||||
/** @type {PortraitFallbackHandler} */
|
||||
let handler;
|
||||
|
||||
beforeEach(() => {
|
||||
adapter = createFoundryAdapterMock({
|
||||
users: {
|
||||
get: vi.fn(),
|
||||
},
|
||||
});
|
||||
playerPrivacyManager = createPlayerPrivacyManagerMock();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
if (handler) {
|
||||
handler.teardown();
|
||||
}
|
||||
});
|
||||
|
||||
describe("constructor", () => {
|
||||
it("should construct with valid adapter and playerPrivacyManager", () => {
|
||||
expect(() => new PortraitFallbackHandler(adapter, playerPrivacyManager)).not.toThrow();
|
||||
});
|
||||
|
||||
it("should throw TypeError for null adapter", () => {
|
||||
expect(() => new PortraitFallbackHandler(null, playerPrivacyManager)).toThrow(TypeError);
|
||||
expect(() => new PortraitFallbackHandler(null, playerPrivacyManager)).toThrow(
|
||||
"PortraitFallbackHandler: adapter argument is required and must be an object"
|
||||
);
|
||||
});
|
||||
|
||||
it("should throw TypeError for null playerPrivacyManager", () => {
|
||||
expect(() => new PortraitFallbackHandler(adapter, null)).toThrow(TypeError);
|
||||
expect(() => new PortraitFallbackHandler(adapter, null)).toThrow(
|
||||
"PortraitFallbackHandler: playerPrivacyManager argument is required and must be an object"
|
||||
);
|
||||
});
|
||||
|
||||
it("should throw TypeError for non-object adapter", () => {
|
||||
expect(() => new PortraitFallbackHandler("not an object", playerPrivacyManager)).toThrow(TypeError);
|
||||
});
|
||||
|
||||
it("should throw TypeError for non-object playerPrivacyManager", () => {
|
||||
expect(() => new PortraitFallbackHandler(adapter, "not an object")).toThrow(TypeError);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getFallbackImageURL", () => {
|
||||
beforeEach(() => {
|
||||
handler = new PortraitFallbackHandler(adapter, playerPrivacyManager);
|
||||
});
|
||||
|
||||
it("should return custom portrait when set", () => {
|
||||
const customDataURL = "data:image/png;base64,test123";
|
||||
const mockUser = { id: "player1", avatar: null };
|
||||
playerPrivacyManager.getPortraitFallbackDataURL.mockReturnValue(customDataURL);
|
||||
adapter.users.get.mockReturnValue(mockUser);
|
||||
|
||||
const result = handler.getFallbackImageURL("player1");
|
||||
expect(result).toBe(customDataURL);
|
||||
expect(playerPrivacyManager.getPortraitFallbackDataURL).toHaveBeenCalledWith("player1");
|
||||
});
|
||||
|
||||
it("should return FoundryVTT avatar when no custom portrait", () => {
|
||||
playerPrivacyManager.getPortraitFallbackDataURL.mockReturnValue(null);
|
||||
const mockUser = { id: "player1", avatar: "/icons/avatars/player1.jpg" };
|
||||
adapter.users.get.mockReturnValue(mockUser);
|
||||
|
||||
const result = handler.getFallbackImageURL("player1");
|
||||
expect(result).toBe("/icons/avatars/player1.jpg");
|
||||
});
|
||||
|
||||
it("should return system placeholder when no avatar and no custom portrait", () => {
|
||||
playerPrivacyManager.getPortraitFallbackDataURL.mockReturnValue(null);
|
||||
const mockUser = { id: "player1", avatar: null };
|
||||
adapter.users.get.mockReturnValue(mockUser);
|
||||
|
||||
const result = handler.getFallbackImageURL("player1");
|
||||
// Should return some default placeholder
|
||||
expect(result).toBeDefined();
|
||||
expect(result).not.toBeNull();
|
||||
});
|
||||
|
||||
it("should return null for non-existent user", () => {
|
||||
playerPrivacyManager.getPortraitFallbackDataURL.mockReturnValue(null);
|
||||
adapter.users.get.mockReturnValue(null);
|
||||
|
||||
const result = handler.getFallbackImageURL("nonexistent");
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("getFallbackImageElement", () => {
|
||||
beforeEach(() => {
|
||||
handler = new PortraitFallbackHandler(adapter, playerPrivacyManager);
|
||||
});
|
||||
|
||||
it("should create img element with src", () => {
|
||||
const dataURL = "data:image/png;base64,test123";
|
||||
const mockUser = { id: "player1", name: "Test Player", avatar: null };
|
||||
playerPrivacyManager.getPortraitFallbackDataURL.mockReturnValue(dataURL);
|
||||
adapter.users.get.mockReturnValue(mockUser);
|
||||
|
||||
const element = handler.getFallbackImageElement("player1");
|
||||
expect(element.tagName.toLowerCase()).toBe("img");
|
||||
expect(element.src).toBe(dataURL);
|
||||
});
|
||||
|
||||
it("should set alt text", () => {
|
||||
const dataURL = "data:image/png;base64,test123";
|
||||
playerPrivacyManager.getPortraitFallbackDataURL.mockReturnValue(dataURL);
|
||||
const mockUser = { id: "player1", name: "Test Player" };
|
||||
adapter.users.get.mockReturnValue(mockUser);
|
||||
|
||||
const element = handler.getFallbackImageElement("player1");
|
||||
expect(element.alt).toContain("Test Player");
|
||||
});
|
||||
|
||||
it("should set class name", () => {
|
||||
const dataURL = "data:image/png;base64,test123";
|
||||
playerPrivacyManager.getPortraitFallbackDataURL.mockReturnValue(dataURL);
|
||||
|
||||
const element = handler.getFallbackImageElement("player1");
|
||||
expect(element.className).toContain("sp-portrait-fallback");
|
||||
});
|
||||
|
||||
it("should set data attribute", () => {
|
||||
const dataURL = "data:image/png;base64,test123";
|
||||
playerPrivacyManager.getPortraitFallbackDataURL.mockReturnValue(dataURL);
|
||||
|
||||
const element = handler.getFallbackImageElement("player1");
|
||||
expect(element.dataset.spRole).toBe("portrait-fallback");
|
||||
});
|
||||
});
|
||||
|
||||
describe("validatePortraitFile", () => {
|
||||
it("should accept valid PNG file", () => {
|
||||
const file = {
|
||||
type: "image/png",
|
||||
size: 1024,
|
||||
name: "test.png",
|
||||
};
|
||||
const result = PortraitFallbackHandler.validatePortraitFile(file);
|
||||
expect(result.valid).toBe(true);
|
||||
});
|
||||
|
||||
it("should accept valid JPEG file", () => {
|
||||
const file = {
|
||||
type: "image/jpeg",
|
||||
size: 2048,
|
||||
name: "test.jpg",
|
||||
};
|
||||
const result = PortraitFallbackHandler.validatePortraitFile(file);
|
||||
expect(result.valid).toBe(true);
|
||||
});
|
||||
|
||||
it("should accept valid WEBP file", () => {
|
||||
const file = {
|
||||
type: "image/webp",
|
||||
size: 512,
|
||||
name: "test.webp",
|
||||
};
|
||||
const result = PortraitFallbackHandler.validatePortraitFile(file);
|
||||
expect(result.valid).toBe(true);
|
||||
});
|
||||
|
||||
it("should accept valid GIF file", () => {
|
||||
const file = {
|
||||
type: "image/gif",
|
||||
size: 1024,
|
||||
name: "test.gif",
|
||||
};
|
||||
const result = PortraitFallbackHandler.validatePortraitFile(file);
|
||||
expect(result.valid).toBe(true);
|
||||
});
|
||||
|
||||
it("should reject SVG file", () => {
|
||||
const file = {
|
||||
type: "image/svg+xml",
|
||||
size: 512,
|
||||
name: "test.svg",
|
||||
};
|
||||
const result = PortraitFallbackHandler.validatePortraitFile(file);
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.error).toContain("Unsupported format");
|
||||
});
|
||||
|
||||
it("should reject MP4 file", () => {
|
||||
const file = {
|
||||
type: "video/mp4",
|
||||
size: 1024,
|
||||
name: "test.mp4",
|
||||
};
|
||||
const result = PortraitFallbackHandler.validatePortraitFile(file);
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.error).toContain("Unsupported format");
|
||||
});
|
||||
|
||||
it("should reject file over size limit", () => {
|
||||
const file = {
|
||||
type: "image/png",
|
||||
size: 6 * 1024 * 1024, // 6MB, over 5MB limit
|
||||
name: "test.png",
|
||||
};
|
||||
const result = PortraitFallbackHandler.validatePortraitFile(file);
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.error).toContain("too large");
|
||||
});
|
||||
|
||||
it("should accept file at exactly size limit", () => {
|
||||
const file = {
|
||||
type: "image/png",
|
||||
size: 5 * 1024 * 1024, // Exactly 5MB
|
||||
name: "test.png",
|
||||
};
|
||||
const result = PortraitFallbackHandler.validatePortraitFile(file);
|
||||
expect(result.valid).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("fileToDataURL", () => {
|
||||
it("should convert File to DataURL", async () => {
|
||||
// Create a mock File object
|
||||
const file = new File([new Uint8Array([1, 2, 3])], "test.png", { type: "image/png" });
|
||||
|
||||
const result = await PortraitFallbackHandler.fileToDataURL(file);
|
||||
expect(result).toBeDefined();
|
||||
expect(result).toBeTypeOf("string");
|
||||
expect(result).toMatch(/^data:image\/png;/);
|
||||
});
|
||||
|
||||
it("should handle FileReader errors", async () => {
|
||||
// This is hard to test directly, but the implementation should handle errors
|
||||
// We'll test the happy path and trust error handling
|
||||
const file = new File([], "empty.png", { type: "image/png" });
|
||||
const result = await PortraitFallbackHandler.fileToDataURL(file);
|
||||
expect(result).toBeDefined();
|
||||
expect(result).toBeTypeOf("string");
|
||||
});
|
||||
});
|
||||
|
||||
describe("event emission", () => {
|
||||
beforeEach(() => {
|
||||
handler = new PortraitFallbackHandler(adapter, playerPrivacyManager);
|
||||
});
|
||||
|
||||
it("should emit portraitChanged event on custom portrait set", () => {
|
||||
// Note: This would require integration with Hooks or similar
|
||||
// For now, we test that the handler is created successfully
|
||||
expect(handler).toBeDefined();
|
||||
});
|
||||
|
||||
it("should allow subscription to portrait change events", () => {
|
||||
const callback = vi.fn();
|
||||
const unsubscribe = handler.onPortraitChange(callback);
|
||||
expect(typeof unsubscribe).toBe("function");
|
||||
unsubscribe();
|
||||
});
|
||||
});
|
||||
|
||||
describe("teardown", () => {
|
||||
it("should clean up without errors", () => {
|
||||
handler = new PortraitFallbackHandler(adapter, playerPrivacyManager);
|
||||
expect(() => handler.teardown()).not.toThrow();
|
||||
});
|
||||
|
||||
it("should be safe to call multiple times", () => {
|
||||
handler = new PortraitFallbackHandler(adapter, playerPrivacyManager);
|
||||
handler.teardown();
|
||||
handler.teardown();
|
||||
expect(() => handler.teardown()).not.toThrow();
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user