many changes
This commit is contained in:
@@ -0,0 +1,431 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { render, waitFor } from "@testing-library/svelte";
|
||||
|
||||
/**
|
||||
* Integration tests for async image loading pattern used in components
|
||||
*
|
||||
* Pattern:
|
||||
* - Component has $state<string> imageUrl = ""
|
||||
* - Component has async loadImageUrl() function
|
||||
* - Component uses $effect to call loadImageUrl when dependencies change
|
||||
* - For lists: uses Map<string, string> to cache URLs per item
|
||||
*/
|
||||
|
||||
// Mock repository with getImageUrl
|
||||
const createMockRepository = () => ({
|
||||
getImageUrl: vi.fn(),
|
||||
});
|
||||
|
||||
describe("Async Image Loading Pattern", () => {
|
||||
let mockRepository: any;
|
||||
|
||||
beforeEach(() => {
|
||||
mockRepository = createMockRepository();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllTimers();
|
||||
});
|
||||
|
||||
describe("Single Image Loading", () => {
|
||||
it("should load image URL asynchronously on component mount", async () => {
|
||||
mockRepository.getImageUrl.mockResolvedValue("https://server.com/image.jpg");
|
||||
|
||||
// Simulating component with async image loading
|
||||
const imageUrl = await mockRepository.getImageUrl("item123", "Primary");
|
||||
|
||||
expect(imageUrl).toBe("https://server.com/image.jpg");
|
||||
expect(mockRepository.getImageUrl).toHaveBeenCalledWith("item123", "Primary");
|
||||
});
|
||||
|
||||
it("should show placeholder while loading", async () => {
|
||||
mockRepository.getImageUrl.mockImplementation(
|
||||
() => new Promise((resolve) => setTimeout(() => resolve("https://server.com/image.jpg"), 100))
|
||||
);
|
||||
|
||||
vi.useFakeTimers();
|
||||
const promise = mockRepository.getImageUrl("item123", "Primary");
|
||||
|
||||
// Initially no URL
|
||||
expect(promise).toBeInstanceOf(Promise);
|
||||
|
||||
vi.advanceTimersByTime(100);
|
||||
vi.useRealTimers();
|
||||
|
||||
const result = await promise;
|
||||
expect(result).toBe("https://server.com/image.jpg");
|
||||
});
|
||||
|
||||
it("should reload image when item changes", async () => {
|
||||
mockRepository.getImageUrl.mockResolvedValue("https://server.com/image1.jpg");
|
||||
|
||||
const url1 = await mockRepository.getImageUrl("item1", "Primary");
|
||||
expect(url1).toBe("https://server.com/image1.jpg");
|
||||
|
||||
mockRepository.getImageUrl.mockResolvedValue("https://server.com/image2.jpg");
|
||||
|
||||
const url2 = await mockRepository.getImageUrl("item2", "Primary");
|
||||
expect(url2).toBe("https://server.com/image2.jpg");
|
||||
|
||||
expect(mockRepository.getImageUrl).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("should not reload image if item ID hasn't changed", async () => {
|
||||
mockRepository.getImageUrl.mockResolvedValue("https://server.com/image.jpg");
|
||||
|
||||
// First load
|
||||
await mockRepository.getImageUrl("item123", "Primary");
|
||||
|
||||
// Would normally use $effect to track changes
|
||||
// If item ID is same, should not reload (handled by component caching)
|
||||
// This test documents the expected behavior
|
||||
});
|
||||
|
||||
it("should handle load errors gracefully", async () => {
|
||||
mockRepository.getImageUrl.mockRejectedValue(new Error("Network error"));
|
||||
|
||||
// Component should catch error and show placeholder
|
||||
try {
|
||||
await mockRepository.getImageUrl("item123", "Primary");
|
||||
} catch (e) {
|
||||
expect(e).toBeInstanceOf(Error);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("List Image Caching (Map-based)", () => {
|
||||
it("should cache URLs using Map<string, string>", () => {
|
||||
// Simulating component state: imageUrls = $state<Map<string, string>>(new Map())
|
||||
const imageUrls = new Map<string, string>();
|
||||
|
||||
// Load first item
|
||||
imageUrls.set("item1", "https://server.com/image1.jpg");
|
||||
expect(imageUrls.has("item1")).toBe(true);
|
||||
expect(imageUrls.get("item1")).toBe("https://server.com/image1.jpg");
|
||||
|
||||
// Load second item
|
||||
imageUrls.set("item2", "https://server.com/image2.jpg");
|
||||
expect(imageUrls.size).toBe(2);
|
||||
|
||||
// Check cache hit
|
||||
expect(imageUrls.get("item1")).toBe("https://server.com/image1.jpg");
|
||||
});
|
||||
|
||||
it("should load images only once per item", async () => {
|
||||
mockRepository.getImageUrl.mockResolvedValue("https://server.com/image.jpg");
|
||||
|
||||
const imageUrls = new Map<string, string>();
|
||||
|
||||
// Simulate loading multiple items
|
||||
const items = [
|
||||
{ id: "item1", name: "Album 1" },
|
||||
{ id: "item2", name: "Album 2" },
|
||||
{ id: "item1", name: "Album 1 (again)" }, // Same ID
|
||||
];
|
||||
|
||||
for (const item of items) {
|
||||
if (!imageUrls.has(item.id)) {
|
||||
const url = await mockRepository.getImageUrl(item.id, "Primary");
|
||||
imageUrls.set(item.id, url);
|
||||
}
|
||||
}
|
||||
|
||||
// Should only call once per unique ID
|
||||
expect(mockRepository.getImageUrl).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("should update single item without affecting others", async () => {
|
||||
const imageUrls = new Map<string, string>();
|
||||
|
||||
imageUrls.set("item1", "https://server.com/image1.jpg");
|
||||
imageUrls.set("item2", "https://server.com/image2.jpg");
|
||||
imageUrls.set("item3", "https://server.com/image3.jpg");
|
||||
|
||||
// Update item2
|
||||
mockRepository.getImageUrl.mockResolvedValue("https://server.com/image2_updated.jpg");
|
||||
const newUrl = await mockRepository.getImageUrl("item2", "Primary");
|
||||
imageUrls.set("item2", newUrl);
|
||||
|
||||
// Others should remain unchanged
|
||||
expect(imageUrls.get("item1")).toBe("https://server.com/image1.jpg");
|
||||
expect(imageUrls.get("item2")).toBe("https://server.com/image2_updated.jpg");
|
||||
expect(imageUrls.get("item3")).toBe("https://server.com/image3.jpg");
|
||||
});
|
||||
|
||||
it("should clear cache when data changes", () => {
|
||||
const imageUrls = new Map<string, string>();
|
||||
|
||||
imageUrls.set("item1", "https://server.com/image1.jpg");
|
||||
imageUrls.set("item2", "https://server.com/image2.jpg");
|
||||
|
||||
// Clear cache
|
||||
imageUrls.clear();
|
||||
|
||||
expect(imageUrls.size).toBe(0);
|
||||
expect(imageUrls.has("item1")).toBe(false);
|
||||
});
|
||||
|
||||
it("should support Map operations efficiently", () => {
|
||||
const imageUrls = new Map<string, string>();
|
||||
|
||||
// Add items
|
||||
for (let i = 0; i < 100; i++) {
|
||||
imageUrls.set(`item${i}`, `https://server.com/image${i}.jpg`);
|
||||
}
|
||||
|
||||
expect(imageUrls.size).toBe(100);
|
||||
|
||||
// Check specific item
|
||||
expect(imageUrls.has("item50")).toBe(true);
|
||||
expect(imageUrls.get("item50")).toBe("https://server.com/image50.jpg");
|
||||
|
||||
// Iterate
|
||||
let count = 0;
|
||||
imageUrls.forEach(() => {
|
||||
count++;
|
||||
});
|
||||
expect(count).toBe(100);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Component Lifecycle ($effect integration)", () => {
|
||||
it("should trigger load on prop change", async () => {
|
||||
mockRepository.getImageUrl.mockResolvedValue("https://server.com/image.jpg");
|
||||
|
||||
// Simulate $effect tracking prop changes
|
||||
let effectCount = 0;
|
||||
const trackingEffect = vi.fn(() => {
|
||||
effectCount++;
|
||||
return mockRepository.getImageUrl("item123", "Primary");
|
||||
});
|
||||
|
||||
trackingEffect();
|
||||
expect(effectCount).toBe(1);
|
||||
|
||||
trackingEffect();
|
||||
expect(effectCount).toBe(2);
|
||||
});
|
||||
|
||||
it("should skip load if conditions not met", async () => {
|
||||
mockRepository.getImageUrl.mockResolvedValue("https://server.com/image.jpg");
|
||||
|
||||
// Simulate conditional loading (e.g., if (!imageUrl && primaryImageTag))
|
||||
let imageUrl = "";
|
||||
const primaryImageTag = "";
|
||||
|
||||
if (!imageUrl && primaryImageTag) {
|
||||
imageUrl = await mockRepository.getImageUrl("item123", "Primary");
|
||||
}
|
||||
|
||||
expect(mockRepository.getImageUrl).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should handle dependent state updates", async () => {
|
||||
mockRepository.getImageUrl.mockResolvedValue("https://server.com/image.jpg");
|
||||
|
||||
// Simulate component state changes triggering effects
|
||||
const state = {
|
||||
item: { id: "item1", primaryImageTag: "tag1" },
|
||||
imageUrl: "",
|
||||
};
|
||||
|
||||
const loadImage = async () => {
|
||||
if (state.item.primaryImageTag) {
|
||||
state.imageUrl = await mockRepository.getImageUrl(state.item.id, "Primary");
|
||||
}
|
||||
};
|
||||
|
||||
await loadImage();
|
||||
expect(state.imageUrl).toBe("https://server.com/image.jpg");
|
||||
|
||||
// Change item
|
||||
state.item = { id: "item2", primaryImageTag: "tag2" };
|
||||
state.imageUrl = "";
|
||||
|
||||
mockRepository.getImageUrl.mockResolvedValue("https://server.com/image2.jpg");
|
||||
await loadImage();
|
||||
expect(state.imageUrl).toBe("https://server.com/image2.jpg");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Error Handling in Async Loading", () => {
|
||||
it("should set empty string on error", async () => {
|
||||
mockRepository.getImageUrl.mockRejectedValue(new Error("Network error"));
|
||||
|
||||
let imageUrl = "";
|
||||
|
||||
try {
|
||||
imageUrl = await mockRepository.getImageUrl("item123", "Primary");
|
||||
} catch {
|
||||
imageUrl = ""; // Set to empty on error
|
||||
}
|
||||
|
||||
expect(imageUrl).toBe("");
|
||||
});
|
||||
|
||||
it("should allow retry after error", async () => {
|
||||
mockRepository.getImageUrl
|
||||
.mockRejectedValueOnce(new Error("Network error"))
|
||||
.mockResolvedValueOnce("https://server.com/image.jpg");
|
||||
|
||||
let imageUrl = "";
|
||||
|
||||
// First attempt fails
|
||||
try {
|
||||
imageUrl = await mockRepository.getImageUrl("item123", "Primary");
|
||||
} catch {
|
||||
imageUrl = "";
|
||||
}
|
||||
|
||||
// Retry succeeds
|
||||
imageUrl = await mockRepository.getImageUrl("item123", "Primary");
|
||||
expect(imageUrl).toBe("https://server.com/image.jpg");
|
||||
});
|
||||
|
||||
it("should handle concurrent load requests", async () => {
|
||||
mockRepository.getImageUrl.mockResolvedValue("https://server.com/image.jpg");
|
||||
|
||||
// Simulate loading multiple images concurrently
|
||||
const imageUrls = new Map<string, string>();
|
||||
const items = [
|
||||
{ id: "item1" },
|
||||
{ id: "item2" },
|
||||
{ id: "item3" },
|
||||
];
|
||||
|
||||
const promises = items.map(item =>
|
||||
mockRepository.getImageUrl(item.id, "Primary")
|
||||
.then(url => imageUrls.set(item.id, url))
|
||||
.catch(() => imageUrls.set(item.id, ""))
|
||||
);
|
||||
|
||||
await Promise.all(promises);
|
||||
|
||||
expect(imageUrls.size).toBe(3);
|
||||
expect(imageUrls.has("item1")).toBe(true);
|
||||
expect(imageUrls.has("item2")).toBe(true);
|
||||
expect(imageUrls.has("item3")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Performance Characteristics", () => {
|
||||
it("should not reload unnecessarily", async () => {
|
||||
mockRepository.getImageUrl.mockResolvedValue("https://server.com/image.jpg");
|
||||
|
||||
// Simulate $effect with dependency tracking
|
||||
let dependencyValue = "same";
|
||||
let previousDependency = "same";
|
||||
|
||||
const loadImage = async () => {
|
||||
if (dependencyValue !== previousDependency) {
|
||||
previousDependency = dependencyValue;
|
||||
return await mockRepository.getImageUrl("item123", "Primary");
|
||||
}
|
||||
};
|
||||
|
||||
await loadImage();
|
||||
expect(mockRepository.getImageUrl).toHaveBeenCalledTimes(1);
|
||||
|
||||
// No change in dependency
|
||||
await loadImage();
|
||||
expect(mockRepository.getImageUrl).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Change dependency
|
||||
dependencyValue = "changed";
|
||||
await loadImage();
|
||||
expect(mockRepository.getImageUrl).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("should handle large lists efficiently", async () => {
|
||||
const imageUrls = new Map<string, string>();
|
||||
let loadCount = 0;
|
||||
|
||||
mockRepository.getImageUrl.mockImplementation(() => {
|
||||
loadCount++;
|
||||
return Promise.resolve("https://server.com/image.jpg");
|
||||
});
|
||||
|
||||
// Simulate loading 1000 items but caching URLs
|
||||
const items = Array.from({ length: 1000 }, (_, i) => ({ id: `item${i % 10}` }));
|
||||
|
||||
for (const item of items) {
|
||||
if (!imageUrls.has(item.id)) {
|
||||
const url = await mockRepository.getImageUrl(item.id, "Primary");
|
||||
imageUrls.set(item.id, url);
|
||||
}
|
||||
}
|
||||
|
||||
// Should only load 10 unique images
|
||||
expect(loadCount).toBe(10);
|
||||
expect(imageUrls.size).toBe(10);
|
||||
});
|
||||
|
||||
it("should not block rendering during async loading", () => {
|
||||
mockRepository.getImageUrl.mockImplementation(
|
||||
() => new Promise((resolve) =>
|
||||
setTimeout(() => resolve("https://server.com/image.jpg"), 1000)
|
||||
)
|
||||
);
|
||||
|
||||
// Async operation should not block component rendering
|
||||
const renderTiming = {
|
||||
startRender: Date.now(),
|
||||
loadStart: null as number | null,
|
||||
loadComplete: null as number | null,
|
||||
};
|
||||
|
||||
// Render happens immediately
|
||||
renderTiming.startRender = Date.now();
|
||||
|
||||
// Load happens asynchronously
|
||||
mockRepository.getImageUrl("item123", "Primary").then(() => {
|
||||
renderTiming.loadComplete = Date.now();
|
||||
});
|
||||
|
||||
// Render should complete before load finishes
|
||||
expect(Date.now() - renderTiming.startRender).toBeLessThan(1000);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Backend Integration", () => {
|
||||
it("should call backend with correct parameters", async () => {
|
||||
mockRepository.getImageUrl.mockResolvedValue("https://server.com/image.jpg");
|
||||
|
||||
await mockRepository.getImageUrl("item123", "Primary", {
|
||||
maxWidth: 300,
|
||||
});
|
||||
|
||||
expect(mockRepository.getImageUrl).toHaveBeenCalledWith(
|
||||
"item123",
|
||||
"Primary",
|
||||
{
|
||||
maxWidth: 300,
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
it("should handle backend URL correctly", async () => {
|
||||
const backendUrl = "https://server.com/Items/item123/Images/Primary?maxWidth=300&api_key=token";
|
||||
mockRepository.getImageUrl.mockResolvedValue(backendUrl);
|
||||
|
||||
const url = await mockRepository.getImageUrl("item123", "Primary", { maxWidth: 300 });
|
||||
|
||||
expect(url).toBe(backendUrl);
|
||||
// Frontend never constructs URLs directly
|
||||
expect(url).toContain("api_key=");
|
||||
});
|
||||
|
||||
it("should not require URL construction in frontend", async () => {
|
||||
// Frontend receives pre-constructed URL from backend
|
||||
const preConstructedUrl = "https://server.com/Items/item123/Images/Primary?api_key=token";
|
||||
mockRepository.getImageUrl.mockResolvedValue(preConstructedUrl);
|
||||
|
||||
const url = await mockRepository.getImageUrl("item123", "Primary");
|
||||
|
||||
// Frontend just uses the URL
|
||||
expect(url).toContain("https://");
|
||||
expect(url).toContain("item123");
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user