diff --git a/apps/api/src/routes/tools/color-adjustments.ts b/apps/api/src/routes/tools/color-adjustments.ts index 5fa2e751..49d66c1b 100644 --- a/apps/api/src/routes/tools/color-adjustments.ts +++ b/apps/api/src/routes/tools/color-adjustments.ts @@ -73,9 +73,19 @@ async function processColorAdjustments( image = await adjustContrast(image, { value: settings.contrast }); } if (settings.exposure !== 0) { - // Map -100..+100 to gamma 3.0..0.33 (lower gamma = brighter midtones) - const gamma = 1 / (1 + settings.exposure / 100); - image = image.gamma(gamma); + // Sharp's gamma() accepts values between 1.0 and 3.0. + // Positive exposure → brighten midtones → apply gamma > 1.0 on output. + // Negative exposure → darken midtones → apply gamma > 1.0 on input. + // Map -100..+100 to a gamma range of 1.0..3.0 applied on the correct side. + const strength = Math.abs(settings.exposure) / 100; + const g = 1 + strength * 2; // 1.0..3.0 + if (settings.exposure > 0) { + // Brighten: linearize at gamma 1.0, encode at higher gamma + image = image.gamma(1, g); + } else { + // Darken: linearize at higher gamma, encode at gamma 1.0 + image = image.gamma(g, 1); + } } // Color diff --git a/package.json b/package.json index a6a057b8..9011fa34 100644 --- a/package.json +++ b/package.json @@ -44,7 +44,9 @@ "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.2", "@testing-library/user-event": "^14.6.1", + "@types/adm-zip": "^0.5.8", "@vitest/coverage-v8": "^3.2.4", + "adm-zip": "^0.5.17", "husky": "^9.1.7", "jsdom": "^29.0.1", "lint-staged": "^16.4.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d4c177aa..c42ae7e2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -54,9 +54,15 @@ importers: '@testing-library/user-event': specifier: ^14.6.1 version: 14.6.1(@testing-library/dom@10.4.1) + '@types/adm-zip': + specifier: ^0.5.8 + version: 0.5.8 '@vitest/coverage-v8': specifier: ^3.2.4 version: 3.2.4(vitest@3.2.4(@types/debug@4.1.13)(@types/node@22.19.15)(jiti@2.6.1)(jsdom@29.0.1(@noble/hashes@1.8.0))(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.8.3)) + adm-zip: + specifier: ^0.5.17 + version: 0.5.17 husky: specifier: ^9.1.7 version: 9.1.7 @@ -2976,6 +2982,9 @@ packages: cpu: [arm64] os: [win32] + '@types/adm-zip@0.5.8': + resolution: {integrity: sha512-RVVH7QvZYbN+ihqZ4kX/dMiowf6o+Jk1fNwiSdx0NahBJLU787zkULhGhJM8mf/obmLGmgdMM0bXsQTmyfbR7Q==} + '@types/archiver@7.0.0': resolution: {integrity: sha512-/3vwGwx9n+mCQdYZ2IKGGHEFL30I96UgBlk8EtRDDFQ9uxM1l4O5Ci6r00EMAkiDaTqD9DQ6nVrWRICnBPtzzg==} @@ -3259,6 +3268,10 @@ packages: engines: {node: '>=0.4.0'} hasBin: true + adm-zip@0.5.17: + resolution: {integrity: sha512-+Ut8d9LLqwEvHHJl1+PIHqoyDxFgVN847JTVM3Izi3xHDWPE4UtzzXysMZQs64DMcrJfBeS/uoEP4AD3HQHnQQ==} + engines: {node: '>=12.0'} + agent-base@7.1.4: resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==} engines: {node: '>= 14'} @@ -9053,6 +9066,10 @@ snapshots: '@turbo/windows-arm64@2.8.20': optional: true + '@types/adm-zip@0.5.8': + dependencies: + '@types/node': 22.19.15 + '@types/archiver@7.0.0': dependencies: '@types/readdir-glob': 1.1.5 @@ -9389,6 +9406,8 @@ snapshots: acorn@8.16.0: {} + adm-zip@0.5.17: {} + agent-base@7.1.4: {} aggregate-error@3.1.0: diff --git a/tests/e2e-docker/adjustment-tools.spec.ts b/tests/e2e-docker/adjustment-tools.spec.ts new file mode 100644 index 00000000..e60a71b4 --- /dev/null +++ b/tests/e2e-docker/adjustment-tools.spec.ts @@ -0,0 +1,400 @@ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { expect, test } from "@playwright/test"; + +// ─── Adjustment Tools ─────────────────────────────────────────────── +// Tests for: color-adjustments, sharpening, replace-color, strip-metadata, +// edit-metadata, optimize-for-web, image-enhancement +// These tools modify image properties without changing dimensions. + +const FIXTURES = join(process.cwd(), "tests", "fixtures"); +const FORMATS = join(FIXTURES, "formats"); + +let token: string; + +test.beforeAll(async ({ request }) => { + const res = await request.post("/api/auth/login", { + data: { username: "admin", password: "admin" }, + }); + const body = await res.json(); + token = body.token; +}); + +function fixture(name: string): Buffer { + return readFileSync(join(FIXTURES, name)); +} + +const PNG_200x150 = fixture("test-200x150.png"); +const JPG_100x100 = fixture("test-100x100.jpg"); +const JPG_WITH_EXIF = fixture("test-with-exif.jpg"); +const JPG_SAMPLE = readFileSync(join(FORMATS, "sample.jpg")); + +// ─── Color Adjustments ────────────────────────────────────────────── + +test.describe("Color Adjustments", () => { + test("adjust brightness", async ({ request }) => { + const res = await request.post("/api/v1/tools/adjust-colors", { + headers: { Authorization: `Bearer ${token}` }, + multipart: { + file: { name: "test.png", mimeType: "image/png", buffer: PNG_200x150 }, + settings: JSON.stringify({ brightness: 20 }), + }, + }); + expect(res.ok()).toBe(true); + const body = await res.json(); + expect(body.downloadUrl).toBeTruthy(); + expect(body.processedSize).toBeGreaterThan(0); + }); + + test("adjust contrast", async ({ request }) => { + const res = await request.post("/api/v1/tools/adjust-colors", { + headers: { Authorization: `Bearer ${token}` }, + multipart: { + file: { name: "test.jpg", mimeType: "image/jpeg", buffer: JPG_100x100 }, + settings: JSON.stringify({ contrast: 30 }), + }, + }); + expect(res.ok()).toBe(true); + const body = await res.json(); + expect(body.downloadUrl).toBeTruthy(); + }); + + test("adjust saturation", async ({ request }) => { + const res = await request.post("/api/v1/tools/adjust-colors", { + headers: { Authorization: `Bearer ${token}` }, + multipart: { + file: { name: "test.jpg", mimeType: "image/jpeg", buffer: JPG_100x100 }, + settings: JSON.stringify({ saturation: -20 }), + }, + }); + expect(res.ok()).toBe(true); + const body = await res.json(); + expect(body.downloadUrl).toBeTruthy(); + }); + + test("apply grayscale", async ({ request }) => { + const res = await request.post("/api/v1/tools/adjust-colors", { + headers: { Authorization: `Bearer ${token}` }, + multipart: { + file: { name: "test.png", mimeType: "image/png", buffer: PNG_200x150 }, + settings: JSON.stringify({ grayscale: true }), + }, + }); + expect(res.ok()).toBe(true); + const body = await res.json(); + expect(body.downloadUrl).toBeTruthy(); + }); + + test("multiple adjustments at once", async ({ request }) => { + const res = await request.post("/api/v1/tools/adjust-colors", { + headers: { Authorization: `Bearer ${token}` }, + multipart: { + file: { name: "test.jpg", mimeType: "image/jpeg", buffer: JPG_100x100 }, + settings: JSON.stringify({ + brightness: 10, + contrast: 15, + saturation: -10, + }), + }, + }); + expect(res.ok()).toBe(true); + const body = await res.json(); + expect(body.downloadUrl).toBeTruthy(); + }); +}); + +// ─── Sharpening ───────────────────────────────────────────────────── + +test.describe("Sharpening", () => { + test("sharpen with default settings", async ({ request }) => { + const res = await request.post("/api/v1/tools/sharpening", { + headers: { Authorization: `Bearer ${token}` }, + multipart: { + file: { name: "test.jpg", mimeType: "image/jpeg", buffer: JPG_100x100 }, + settings: JSON.stringify({}), + }, + }); + expect(res.ok()).toBe(true); + const body = await res.json(); + expect(body.downloadUrl).toBeTruthy(); + expect(body.processedSize).toBeGreaterThan(0); + }); + + test("sharpen with explicit sigma and amount", async ({ request }) => { + const res = await request.post("/api/v1/tools/sharpening", { + headers: { Authorization: `Bearer ${token}` }, + multipart: { + file: { name: "test.png", mimeType: "image/png", buffer: PNG_200x150 }, + settings: JSON.stringify({ sigma: 1.5, amount: 1.0 }), + }, + }); + expect(res.ok()).toBe(true); + const body = await res.json(); + expect(body.downloadUrl).toBeTruthy(); + }); + + test("heavy sharpening changes file size", async ({ request }) => { + const res = await request.post("/api/v1/tools/sharpening", { + headers: { Authorization: `Bearer ${token}` }, + multipart: { + file: { name: "sample.jpg", mimeType: "image/jpeg", buffer: JPG_SAMPLE }, + settings: JSON.stringify({ sigma: 3.0, amount: 2.0 }), + }, + }); + expect(res.ok()).toBe(true); + const body = await res.json(); + expect(body.downloadUrl).toBeTruthy(); + // Sharpening adds high-frequency detail, so file may differ in size + expect(body.processedSize).toBeGreaterThan(0); + }); +}); + +// ─── Replace Color ────────────────────────────────────────────────── + +test.describe("Replace Color", () => { + test("replace white with black", async ({ request }) => { + const res = await request.post("/api/v1/tools/replace-color", { + headers: { Authorization: `Bearer ${token}` }, + multipart: { + file: { name: "test.png", mimeType: "image/png", buffer: PNG_200x150 }, + settings: JSON.stringify({ + targetColor: "#ffffff", + replacementColor: "#000000", + tolerance: 30, + }), + }, + }); + expect(res.ok()).toBe(true); + const body = await res.json(); + expect(body.downloadUrl).toBeTruthy(); + expect(body.processedSize).toBeGreaterThan(0); + }); + + test("replace with low tolerance", async ({ request }) => { + const res = await request.post("/api/v1/tools/replace-color", { + headers: { Authorization: `Bearer ${token}` }, + multipart: { + file: { name: "test.png", mimeType: "image/png", buffer: PNG_200x150 }, + settings: JSON.stringify({ + targetColor: "#ff0000", + replacementColor: "#00ff00", + tolerance: 5, + }), + }, + }); + expect(res.ok()).toBe(true); + const body = await res.json(); + expect(body.downloadUrl).toBeTruthy(); + }); + + test("replace with high tolerance", async ({ request }) => { + const res = await request.post("/api/v1/tools/replace-color", { + headers: { Authorization: `Bearer ${token}` }, + multipart: { + file: { name: "test.jpg", mimeType: "image/jpeg", buffer: JPG_100x100 }, + settings: JSON.stringify({ + targetColor: "#808080", + replacementColor: "#0000ff", + tolerance: 80, + }), + }, + }); + expect(res.ok()).toBe(true); + const body = await res.json(); + expect(body.downloadUrl).toBeTruthy(); + }); +}); + +// ─── Strip Metadata ───────────────────────────────────────────────── + +test.describe("Strip Metadata", () => { + test("strip metadata from JPEG with EXIF", async ({ request }) => { + const res = await request.post("/api/v1/tools/strip-metadata", { + headers: { Authorization: `Bearer ${token}` }, + multipart: { + file: { name: "test.jpg", mimeType: "image/jpeg", buffer: JPG_WITH_EXIF }, + settings: JSON.stringify({}), + }, + }); + expect(res.ok()).toBe(true); + const body = await res.json(); + expect(body.downloadUrl).toBeTruthy(); + expect(body.processedSize).toBeGreaterThan(0); + // Stripped image should be smaller or equal (no EXIF overhead) + expect(body.processedSize).toBeLessThanOrEqual(body.originalSize); + }); + + test("strip metadata from PNG", async ({ request }) => { + const res = await request.post("/api/v1/tools/strip-metadata", { + headers: { Authorization: `Bearer ${token}` }, + multipart: { + file: { name: "test.png", mimeType: "image/png", buffer: PNG_200x150 }, + settings: JSON.stringify({}), + }, + }); + expect(res.ok()).toBe(true); + const body = await res.json(); + expect(body.downloadUrl).toBeTruthy(); + }); + + test("stripped image has no EXIF data", async ({ request }) => { + // First strip metadata + const stripRes = await request.post("/api/v1/tools/strip-metadata", { + headers: { Authorization: `Bearer ${token}` }, + multipart: { + file: { name: "test.jpg", mimeType: "image/jpeg", buffer: JPG_WITH_EXIF }, + settings: JSON.stringify({}), + }, + }); + expect(stripRes.ok()).toBe(true); + const stripBody = await stripRes.json(); + + // Download the stripped image + const downloadRes = await request.get(stripBody.downloadUrl, { + headers: { Authorization: `Bearer ${token}` }, + }); + expect(downloadRes.ok()).toBe(true); + const strippedBuffer = Buffer.from(await downloadRes.body()); + + // Verify stripped image via info tool + const infoRes = await request.post("/api/v1/tools/info", { + headers: { Authorization: `Bearer ${token}` }, + multipart: { + file: { name: "stripped.jpg", mimeType: "image/jpeg", buffer: strippedBuffer }, + }, + }); + expect(infoRes.ok()).toBe(true); + const infoBody = await infoRes.json(); + expect(infoBody.hasExif).toBe(false); + }); +}); + +// ─── Edit Metadata ────────────────────────────────────────────────── + +test.describe("Edit Metadata", () => { + test("set artist and copyright fields", async ({ request }) => { + const res = await request.post("/api/v1/tools/edit-metadata", { + headers: { Authorization: `Bearer ${token}` }, + multipart: { + file: { name: "test.jpg", mimeType: "image/jpeg", buffer: JPG_100x100 }, + settings: JSON.stringify({ + artist: "Test Artist", + copyright: "CC-BY-4.0", + }), + }, + }); + expect(res.ok()).toBe(true); + const body = await res.json(); + expect(body.downloadUrl).toBeTruthy(); + expect(body.processedSize).toBeGreaterThan(0); + }); + + test("edit metadata on PNG", async ({ request }) => { + const res = await request.post("/api/v1/tools/edit-metadata", { + headers: { Authorization: `Bearer ${token}` }, + multipart: { + file: { name: "test.png", mimeType: "image/png", buffer: PNG_200x150 }, + settings: JSON.stringify({ + title: "Test Image", + description: "A test image for e2e testing", + }), + }, + }); + expect(res.ok()).toBe(true); + const body = await res.json(); + expect(body.downloadUrl).toBeTruthy(); + }); +}); + +// ─── Optimize for Web ─────────────────────────────────────────────── + +test.describe("Optimize for Web", () => { + test("optimize with default settings", async ({ request }) => { + const res = await request.post("/api/v1/tools/optimize-for-web", { + headers: { Authorization: `Bearer ${token}` }, + multipart: { + file: { name: "sample.jpg", mimeType: "image/jpeg", buffer: JPG_SAMPLE }, + settings: JSON.stringify({}), + }, + }); + expect(res.ok()).toBe(true); + const body = await res.json(); + expect(body.downloadUrl).toBeTruthy(); + expect(body.processedSize).toBeGreaterThan(0); + }); + + test("optimize with maxWidth constraint", async ({ request }) => { + const res = await request.post("/api/v1/tools/optimize-for-web", { + headers: { Authorization: `Bearer ${token}` }, + multipart: { + file: { name: "sample.jpg", mimeType: "image/jpeg", buffer: JPG_SAMPLE }, + settings: JSON.stringify({ maxWidth: 800, quality: 75 }), + }, + }); + expect(res.ok()).toBe(true); + const body = await res.json(); + expect(body.downloadUrl).toBeTruthy(); + // Optimized should be smaller than the original large sample + expect(body.processedSize).toBeLessThan(body.originalSize); + }); + + test("optimize PNG image", async ({ request }) => { + const res = await request.post("/api/v1/tools/optimize-for-web", { + headers: { Authorization: `Bearer ${token}` }, + multipart: { + file: { name: "test.png", mimeType: "image/png", buffer: PNG_200x150 }, + settings: JSON.stringify({ maxWidth: 1920, quality: 80 }), + }, + }); + expect(res.ok()).toBe(true); + const body = await res.json(); + expect(body.downloadUrl).toBeTruthy(); + }); +}); + +// ─── Image Enhancement ────────────────────────────────────────────── + +test.describe("Image Enhancement", () => { + test("auto enhancement", async ({ request }) => { + const res = await request.post("/api/v1/tools/image-enhancement", { + headers: { Authorization: `Bearer ${token}` }, + multipart: { + file: { name: "test.jpg", mimeType: "image/jpeg", buffer: JPG_100x100 }, + settings: JSON.stringify({ preset: "auto" }), + }, + }); + expect(res.ok()).toBe(true); + const body = await res.json(); + expect(body.downloadUrl).toBeTruthy(); + expect(body.processedSize).toBeGreaterThan(0); + }); + + test("vivid enhancement preset", async ({ request }) => { + const res = await request.post("/api/v1/tools/image-enhancement", { + headers: { Authorization: `Bearer ${token}` }, + multipart: { + file: { name: "test.jpg", mimeType: "image/jpeg", buffer: JPG_100x100 }, + settings: JSON.stringify({ preset: "vivid" }), + }, + }); + expect(res.ok()).toBe(true); + const body = await res.json(); + expect(body.downloadUrl).toBeTruthy(); + }); + + test("enhancement changes the image", async ({ request }) => { + const res = await request.post("/api/v1/tools/image-enhancement", { + headers: { Authorization: `Bearer ${token}` }, + multipart: { + file: { name: "sample.jpg", mimeType: "image/jpeg", buffer: JPG_SAMPLE }, + settings: JSON.stringify({ preset: "auto" }), + }, + }); + expect(res.ok()).toBe(true); + const body = await res.json(); + expect(body.downloadUrl).toBeTruthy(); + // Enhanced image should differ from original + expect(body.processedSize).toBeGreaterThan(0); + expect(body.processedSize).not.toBe(body.originalSize); + }); +}); diff --git a/tests/e2e-docker/ai-tools.spec.ts b/tests/e2e-docker/ai-tools.spec.ts new file mode 100644 index 00000000..11b62e79 --- /dev/null +++ b/tests/e2e-docker/ai-tools.spec.ts @@ -0,0 +1,743 @@ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { expect, test } from "@playwright/test"; + +// ─── AI Tools ─────────────────────────────────────────────────────── +// Tests for: remove-background, upscale, ocr, blur-faces, smart-crop, +// enhance-faces, colorize, noise-removal, red-eye-removal, restore-photo, +// passport-photo, erase-object +// +// These tools require the AI sidecar (Python bridge). Each test detects +// whether the required feature bundle is installed: +// - 200 = tool works, verify output +// - 501 FEATURE_NOT_INSTALLED = skip gracefully (expected when bundle missing) +// - Other errors = genuine failures + +const FIXTURES = join(process.cwd(), "tests", "fixtures"); +const CONTENT = join(FIXTURES, "content"); + +let token: string; + +test.beforeAll(async ({ request }) => { + const res = await request.post("/api/auth/login", { + data: { username: "admin", password: "admin" }, + }); + const body = await res.json(); + token = body.token; +}); + +function fixture(name: string): Buffer { + return readFileSync(join(FIXTURES, name)); +} + +function contentFixture(name: string): Buffer { + return readFileSync(join(CONTENT, name)); +} + +const JPG_100x100 = fixture("test-100x100.jpg"); +const BLANK_PNG = fixture("test-blank.png"); +const HEIC_PORTRAIT = fixture("test-portrait.heic"); + +/** Minimal valid 1x1 PNG for quick feature-detection probes. */ +const TINY_PNG = fixture("test-1x1.png"); + +/** + * Determine whether a given AI tool's feature bundle is installed by + * querying the features API and checking the status of the bundle that + * enables the tool. + */ +async function isFeatureInstalled( + request: import("@playwright/test").APIRequestContext, + toolId: string, +): Promise { + const res = await request.get("/api/v1/features", { + headers: { Authorization: `Bearer ${token}` }, + }); + if (!res.ok()) return false; + const data = await res.json(); + + // Map tool IDs to their bundle IDs + const toolBundleMap: Record = { + "remove-background": "background-removal", + "passport-photo": "background-removal", + "blur-faces": "face-detection", + "red-eye-removal": "face-detection", + "smart-crop": "face-detection", + "erase-object": "object-eraser-colorize", + colorize: "object-eraser-colorize", + upscale: "upscale-enhance", + "enhance-faces": "upscale-enhance", + "noise-removal": "upscale-enhance", + "restore-photo": "photo-restoration", + ocr: "ocr", + }; + + const bundleId = toolBundleMap[toolId]; + if (!bundleId) return false; + + const bundle = data.bundles?.find((b: { id: string }) => b.id === bundleId); + return bundle?.status === "installed"; +} + +/** + * Post a file to an AI tool and return the response. + * Handles the 501 FEATURE_NOT_INSTALLED case gracefully. + */ +async function callAiTool( + request: import("@playwright/test").APIRequestContext, + toolId: string, + imageBuffer: Buffer, + settings: Record = {}, + filename = "test.png", + mimeType = "image/png", +): Promise<{ installed: boolean; ok: boolean; status: number; body: Record }> { + const res = await request.post(`/api/v1/tools/${toolId}`, { + headers: { Authorization: `Bearer ${token}` }, + multipart: { + file: { name: filename, mimeType, buffer: imageBuffer }, + settings: JSON.stringify(settings), + }, + }); + + const status = res.status(); + const body = await res.json(); + + if (status === 501 && body.code === "FEATURE_NOT_INSTALLED") { + return { installed: false, ok: false, status, body }; + } + + return { installed: true, ok: res.ok(), status, body }; +} + +// ─── Remove Background ────────────────────────────────────────────── + +test.describe("Remove Background", () => { + test("remove background returns download URL or 501", async ({ request }) => { + const result = await callAiTool(request, "remove-background", JPG_100x100, {}); + if (!result.installed) { + expect(result.body.feature).toBe("background-removal"); + expect(result.body.featureName).toBeTruthy(); + test.skip(); + return; + } + expect(result.ok).toBe(true); + expect(result.body.downloadUrl).toBeTruthy(); + expect(result.body.processedSize).toBeGreaterThan(0); + }); + + test("remove background with specific model", async ({ request }) => { + const result = await callAiTool(request, "remove-background", JPG_100x100, { + model: "u2net", + }); + if (!result.installed) { + test.skip(); + return; + } + expect(result.ok).toBe(true); + expect(result.body.downloadUrl).toBeTruthy(); + }); + + test("remove background on HEIC image", async ({ request }) => { + const result = await callAiTool( + request, + "remove-background", + HEIC_PORTRAIT, + {}, + "portrait.heic", + "image/heic", + ); + if (!result.installed) { + test.skip(); + return; + } + expect(result.ok).toBe(true); + expect(result.body.downloadUrl).toBeTruthy(); + }); +}); + +// ─── Upscale ──────────────────────────────────────────────────────── + +test.describe("Upscale", () => { + test("upscale 2x returns larger image or 501", async ({ request }) => { + const result = await callAiTool(request, "upscale", JPG_100x100, { + scale: 2, + model: "auto", + }); + if (!result.installed) { + expect(result.body.feature).toBe("upscale-enhance"); + test.skip(); + return; + } + expect(result.ok).toBe(true); + expect(result.body.downloadUrl).toBeTruthy(); + expect(result.body.processedSize).toBeGreaterThan(0); + }); + + test("upscale 4x", async ({ request }) => { + const result = await callAiTool(request, "upscale", TINY_PNG, { + scale: 4, + model: "auto", + }); + if (!result.installed) { + test.skip(); + return; + } + expect(result.ok).toBe(true); + expect(result.body.downloadUrl).toBeTruthy(); + }); + + test("upscale with lanczos (CPU fallback)", async ({ request }) => { + const result = await callAiTool(request, "upscale", JPG_100x100, { + scale: 2, + model: "lanczos", + }); + if (!result.installed) { + test.skip(); + return; + } + expect(result.ok).toBe(true); + expect(result.body.downloadUrl).toBeTruthy(); + }); + + test("upscale with realesrgan model", async ({ request }) => { + const result = await callAiTool(request, "upscale", TINY_PNG, { + scale: 2, + model: "realesrgan", + }); + if (!result.installed) { + test.skip(); + return; + } + expect(result.ok).toBe(true); + expect(result.body.downloadUrl).toBeTruthy(); + }); +}); + +// ─── OCR ──────────────────────────────────────────────────────────── + +test.describe("OCR", () => { + test("OCR processes image or returns 501", async ({ request }) => { + const result = await callAiTool(request, "ocr", JPG_100x100, {}); + if (!result.installed) { + expect(result.body.feature).toBe("ocr"); + test.skip(); + return; + } + expect(result.ok).toBe(true); + // OCR response may have text, blocks, or engine field + expect(result.body.text !== undefined || result.body.blocks !== undefined).toBe(true); + }); + + test("OCR with tesseract engine", async ({ request }) => { + const result = await callAiTool(request, "ocr", JPG_100x100, { engine: "tesseract" }); + if (!result.installed) { + test.skip(); + return; + } + expect(result.ok).toBe(true); + }); + + test("OCR with paddleocr engine", async ({ request }) => { + const result = await callAiTool(request, "ocr", JPG_100x100, { engine: "paddleocr" }); + if (!result.installed) { + test.skip(); + return; + } + expect(result.ok).toBe(true); + }); + + test("OCR on Japanese text image", async ({ request }) => { + const ocrJapanese = contentFixture("ocr-japanese.png"); + const result = await callAiTool( + request, + "ocr", + ocrJapanese, + {}, + "ocr-japanese.png", + "image/png", + ); + if (!result.installed) { + test.skip(); + return; + } + expect(result.ok).toBe(true); + }); + + test("OCR on chat screenshot", async ({ request }) => { + const ocrChat = contentFixture("ocr-chat.jpeg"); + const result = await callAiTool(request, "ocr", ocrChat, {}, "ocr-chat.jpeg", "image/jpeg"); + if (!result.installed) { + test.skip(); + return; + } + expect(result.ok).toBe(true); + // Chat screenshot should contain some readable text + if (result.body.text) { + expect((result.body.text as string).length).toBeGreaterThan(0); + } + }); +}); + +// ─── Blur Faces ───────────────────────────────────────────────────── + +test.describe("Blur Faces", () => { + test("blur faces processes image or returns 501", async ({ request }) => { + const portrait = contentFixture("portrait-color.jpg"); + const result = await callAiTool( + request, + "blur-faces", + portrait, + { blurRadius: 30, sensitivity: 0.5 }, + "portrait.jpg", + "image/jpeg", + ); + if (!result.installed) { + expect(result.body.feature).toBe("face-detection"); + test.skip(); + return; + } + expect(result.ok).toBe(true); + expect(result.body.downloadUrl).toBeTruthy(); + }); + + test("blur faces on HEIC portrait", async ({ request }) => { + const result = await callAiTool( + request, + "blur-faces", + HEIC_PORTRAIT, + { blurRadius: 30 }, + "portrait.heic", + "image/heic", + ); + if (!result.installed) { + test.skip(); + return; + } + expect(result.ok).toBe(true); + expect(result.body.downloadUrl).toBeTruthy(); + }); + + test("blur faces on image with no faces returns warning", async ({ request }) => { + const result = await callAiTool(request, "blur-faces", BLANK_PNG, { blurRadius: 30 }); + if (!result.installed) { + test.skip(); + return; + } + // Should succeed but indicate no faces were found + expect(result.ok).toBe(true); + // facesDetected should be 0 or a warning should be present + if (result.body.facesDetected !== undefined) { + expect(result.body.facesDetected).toBe(0); + } + }); + + test("blur faces on multi-face image", async ({ request }) => { + const multiFace = contentFixture("multi-face.webp"); + const result = await callAiTool( + request, + "blur-faces", + multiFace, + { blurRadius: 20 }, + "multi-face.webp", + "image/webp", + ); + if (!result.installed) { + test.skip(); + return; + } + expect(result.ok).toBe(true); + expect(result.body.downloadUrl).toBeTruthy(); + }); +}); + +// ─── Smart Crop ───────────────────────────────────────────────────── + +test.describe("Smart Crop", () => { + test("smart crop to portrait dimensions or returns 501", async ({ request }) => { + const portrait = contentFixture("portrait-color.jpg"); + const result = await callAiTool( + request, + "smart-crop", + portrait, + { width: 400, height: 400 }, + "portrait.jpg", + "image/jpeg", + ); + if (!result.installed) { + expect(result.body.feature).toBe("face-detection"); + test.skip(); + return; + } + expect(result.ok).toBe(true); + expect(result.body.downloadUrl).toBeTruthy(); + }); + + test("smart crop with landscape aspect ratio", async ({ request }) => { + const portrait = contentFixture("portrait-color.jpg"); + const result = await callAiTool( + request, + "smart-crop", + portrait, + { width: 800, height: 400 }, + "portrait.jpg", + "image/jpeg", + ); + if (!result.installed) { + test.skip(); + return; + } + expect(result.ok).toBe(true); + expect(result.body.downloadUrl).toBeTruthy(); + }); +}); + +// ─── Enhance Faces ────────────────────────────────────────────────── + +test.describe("Enhance Faces", () => { + test("enhance faces with auto model or returns 501", async ({ request }) => { + const portrait = contentFixture("portrait-color.jpg"); + const result = await callAiTool( + request, + "enhance-faces", + portrait, + { model: "auto" }, + "portrait.jpg", + "image/jpeg", + ); + if (!result.installed) { + expect(result.body.feature).toBe("upscale-enhance"); + test.skip(); + return; + } + expect(result.ok).toBe(true); + expect(result.body.downloadUrl).toBeTruthy(); + }); + + test("enhance faces with gfpgan model", async ({ request }) => { + const portrait = contentFixture("portrait-color.jpg"); + const result = await callAiTool( + request, + "enhance-faces", + portrait, + { model: "gfpgan" }, + "portrait.jpg", + "image/jpeg", + ); + if (!result.installed) { + test.skip(); + return; + } + expect(result.ok).toBe(true); + expect(result.body.downloadUrl).toBeTruthy(); + }); + + test("enhance faces with codeformer model", async ({ request }) => { + const portrait = contentFixture("portrait-color.jpg"); + const result = await callAiTool( + request, + "enhance-faces", + portrait, + { model: "codeformer" }, + "portrait.jpg", + "image/jpeg", + ); + if (!result.installed) { + test.skip(); + return; + } + expect(result.ok).toBe(true); + expect(result.body.downloadUrl).toBeTruthy(); + }); +}); + +// ─── Colorize ─────────────────────────────────────────────────────── + +test.describe("Colorize", () => { + test("colorize B&W image or returns 501", async ({ request }) => { + const bwPortrait = contentFixture("portrait-bw.jpeg"); + const result = await callAiTool( + request, + "colorize", + bwPortrait, + { model: "auto" }, + "portrait-bw.jpeg", + "image/jpeg", + ); + if (!result.installed) { + expect(result.body.feature).toBe("object-eraser-colorize"); + test.skip(); + return; + } + expect(result.ok).toBe(true); + expect(result.body.downloadUrl).toBeTruthy(); + }); + + test("colorize with ddcolor model", async ({ request }) => { + const bwPortrait = contentFixture("portrait-bw.jpeg"); + const result = await callAiTool( + request, + "colorize", + bwPortrait, + { model: "ddcolor" }, + "portrait-bw.jpeg", + "image/jpeg", + ); + if (!result.installed) { + test.skip(); + return; + } + expect(result.ok).toBe(true); + expect(result.body.downloadUrl).toBeTruthy(); + }); +}); + +// ─── Noise Removal ────────────────────────────────────────────────── + +test.describe("Noise Removal", () => { + test("noise removal with quick tier or returns 501", async ({ request }) => { + const result = await callAiTool(request, "noise-removal", JPG_100x100, { + tier: "quick", + }); + if (!result.installed) { + expect(result.body.feature).toBe("upscale-enhance"); + test.skip(); + return; + } + expect(result.ok).toBe(true); + expect(result.body.downloadUrl).toBeTruthy(); + }); + + test("noise removal with balanced tier", async ({ request }) => { + const result = await callAiTool(request, "noise-removal", JPG_100x100, { + tier: "balanced", + }); + if (!result.installed) { + test.skip(); + return; + } + expect(result.ok).toBe(true); + expect(result.body.downloadUrl).toBeTruthy(); + }); + + test("noise removal with quality tier (SCUNet)", async ({ request }) => { + const result = await callAiTool(request, "noise-removal", JPG_100x100, { + tier: "quality", + }); + if (!result.installed) { + test.skip(); + return; + } + expect(result.ok).toBe(true); + expect(result.body.downloadUrl).toBeTruthy(); + }); + + test("noise removal with maximum tier (NAFNet)", async ({ request }) => { + const result = await callAiTool(request, "noise-removal", TINY_PNG, { + tier: "maximum", + }); + if (!result.installed) { + test.skip(); + return; + } + expect(result.ok).toBe(true); + expect(result.body.downloadUrl).toBeTruthy(); + }); +}); + +// ─── Red-Eye Removal ──────────────────────────────────────────────── + +test.describe("Red-Eye Removal", () => { + test("red-eye removal processes image or returns 501", async ({ request }) => { + const redEye = contentFixture("red-eye.jpg"); + const result = await callAiTool( + request, + "red-eye-removal", + redEye, + {}, + "red-eye.jpg", + "image/jpeg", + ); + if (!result.installed) { + expect(result.body.feature).toBe("face-detection"); + test.skip(); + return; + } + expect(result.ok).toBe(true); + expect(result.body.downloadUrl).toBeTruthy(); + }); + + test("red-eye removal on image without red eyes succeeds gracefully", async ({ request }) => { + const portrait = contentFixture("portrait-color.jpg"); + const result = await callAiTool( + request, + "red-eye-removal", + portrait, + {}, + "portrait.jpg", + "image/jpeg", + ); + if (!result.installed) { + test.skip(); + return; + } + // Should succeed even if no red eyes found + expect(result.ok).toBe(true); + expect(result.body.downloadUrl).toBeTruthy(); + }); +}); + +// ─── Restore Photo ────────────────────────────────────────────────── + +test.describe("Restore Photo", () => { + test("restore photo processes image or returns 501", async ({ request }) => { + const bwPortrait = contentFixture("portrait-bw.jpeg"); + const result = await callAiTool( + request, + "restore-photo", + bwPortrait, + {}, + "old-photo.jpeg", + "image/jpeg", + ); + if (!result.installed) { + expect(result.body.feature).toBe("photo-restoration"); + test.skip(); + return; + } + expect(result.ok).toBe(true); + expect(result.body.downloadUrl).toBeTruthy(); + expect(result.body.processedSize).toBeGreaterThan(0); + }); +}); + +// ─── Passport Photo ───────────────────────────────────────────────── + +test.describe("Passport Photo", () => { + test("passport photo processes portrait or returns 501", async ({ request }) => { + const portrait = contentFixture("portrait-color.jpg"); + const result = await callAiTool( + request, + "passport-photo", + portrait, + {}, + "portrait.jpg", + "image/jpeg", + ); + if (!result.installed) { + expect(result.body.feature).toBe("background-removal"); + test.skip(); + return; + } + // Passport photo may succeed or fail with "no face detected" + if (result.ok) { + expect(result.body.downloadUrl).toBeTruthy(); + } else { + // Acceptable failure if face detection didn't find a face + expect(result.body.error).toBeDefined(); + expect(typeof result.body.error).toBe("string"); + expect(result.body.error).not.toContain("[object Object]"); + } + }); + + test("passport photo error is readable for non-face image", async ({ request }) => { + const result = await callAiTool(request, "passport-photo", BLANK_PNG, {}); + if (!result.installed) { + test.skip(); + return; + } + // Should fail gracefully with a readable error, not [object Object] + if (!result.ok) { + expect(typeof result.body.error).toBe("string"); + expect(result.body.error).not.toContain("[object Object]"); + if (result.body.details) { + expect(typeof result.body.details).toBe("string"); + } + } + }); + + test("passport photo on headshot portrait", async ({ request }) => { + const headshot = contentFixture("portrait-headshot.heic"); + const result = await callAiTool( + request, + "passport-photo", + headshot, + {}, + "headshot.heic", + "image/heic", + ); + if (!result.installed) { + test.skip(); + return; + } + if (result.ok) { + expect(result.body.downloadUrl).toBeTruthy(); + } + }); +}); + +// ─── Erase Object ─────────────────────────────────────────────────── + +test.describe("Erase Object", () => { + test("erase object returns 501 when feature not installed", async ({ request }) => { + const result = await callAiTool(request, "erase-object", JPG_100x100, {}); + if (!result.installed) { + expect(result.body.feature).toBe("object-eraser-colorize"); + expect(result.body.code).toBe("FEATURE_NOT_INSTALLED"); + // This is the expected state — erase-object needs a mask and LaMa model + return; + } + // If installed, it may fail because no mask was provided + // That's still a valid test — we're checking the tool doesn't crash + expect(typeof result.body.error === "string" || result.body.downloadUrl).toBeTruthy(); + }); +}); + +// ─── Feature Bundle Status ────────────────────────────────────────── + +test.describe("AI Feature Bundle Status", () => { + test("all AI tools return correct 501 response when uninstalled", async ({ request }) => { + const aiTools = [ + { tool: "remove-background", bundle: "background-removal" }, + { tool: "upscale", bundle: "upscale-enhance" }, + { tool: "blur-faces", bundle: "face-detection" }, + { tool: "erase-object", bundle: "object-eraser-colorize" }, + { tool: "ocr", bundle: "ocr" }, + { tool: "colorize", bundle: "object-eraser-colorize" }, + { tool: "enhance-faces", bundle: "upscale-enhance" }, + { tool: "noise-removal", bundle: "upscale-enhance" }, + { tool: "red-eye-removal", bundle: "face-detection" }, + { tool: "restore-photo", bundle: "photo-restoration" }, + { tool: "passport-photo/analyze", bundle: "background-removal" }, + ]; + + let testedCount = 0; + + for (const { tool, bundle } of aiTools) { + const installed = await isFeatureInstalled(request, tool); + if (installed) continue; + + testedCount++; + const res = await request.post(`/api/v1/tools/${tool}`, { + headers: { Authorization: `Bearer ${token}` }, + multipart: { + file: { name: "test.png", mimeType: "image/png", buffer: TINY_PNG }, + settings: JSON.stringify({}), + }, + }); + expect(res.status(), `${tool} should return 501`).toBe(501); + const body = await res.json(); + expect(body.code, `${tool} missing code`).toBe("FEATURE_NOT_INSTALLED"); + expect(body.feature, `${tool} wrong bundle`).toBe(bundle); + expect(body.featureName, `${tool} missing featureName`).toBeTruthy(); + expect( + typeof body.estimatedSize === "string", + `${tool} estimatedSize should be a string`, + ).toBe(true); + } + + // If all bundles are installed, skip the test gracefully + if (testedCount === 0) { + test.skip(); + } + }); +}); diff --git a/tests/e2e-docker/conversion-tools.spec.ts b/tests/e2e-docker/conversion-tools.spec.ts new file mode 100644 index 00000000..616ea9dc --- /dev/null +++ b/tests/e2e-docker/conversion-tools.spec.ts @@ -0,0 +1,582 @@ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { expect, test } from "@playwright/test"; + +// ─── Conversion Tools ─────────────────────────────────────────────── +// Tests for: svg-to-raster, vectorize, gif-tools, pdf-to-image, +// image-to-pdf, image-to-base64, favicon, convert (format matrix) +// These tools handle format conversion and encoding. + +const FIXTURES = join(process.cwd(), "tests", "fixtures"); +const FORMATS = join(FIXTURES, "formats"); +const CONTENT = join(FIXTURES, "content"); + +function buildMultipart( + files: Array<{ + name: string; + filename: string; + contentType: string; + buffer: Buffer; + }>, + fields: Array<{ name: string; value: string }>, +): { body: Buffer; contentType: string } { + const boundary = "----PlaywrightBoundary" + Date.now(); + const parts: Buffer[] = []; + for (const file of files) { + parts.push( + Buffer.from( + `--${boundary}\r\nContent-Disposition: form-data; name="${file.name}"; filename="${file.filename}"\r\nContent-Type: ${file.contentType}\r\n\r\n`, + ), + ); + parts.push(file.buffer); + parts.push(Buffer.from("\r\n")); + } + for (const field of fields) { + parts.push( + Buffer.from( + `--${boundary}\r\nContent-Disposition: form-data; name="${field.name}"\r\n\r\n${field.value}\r\n`, + ), + ); + } + parts.push(Buffer.from(`--${boundary}--\r\n`)); + return { + body: Buffer.concat(parts), + contentType: `multipart/form-data; boundary=${boundary}`, + }; +} + +let token: string; + +test.beforeAll(async ({ request }) => { + const res = await request.post("/api/auth/login", { + data: { username: "admin", password: "admin" }, + }); + const body = await res.json(); + token = body.token; +}); + +function fixture(name: string): Buffer { + return readFileSync(join(FIXTURES, name)); +} + +function formatFixture(name: string): Buffer { + return readFileSync(join(FORMATS, name)); +} + +function contentFixture(name: string): Buffer { + return readFileSync(join(CONTENT, name)); +} + +const PNG_200x150 = fixture("test-200x150.png"); +const JPG_100x100 = fixture("test-100x100.jpg"); +const SVG_100x100 = fixture("test-100x100.svg"); +const ANIMATED_GIF = fixture("animated.gif"); +const PDF_3PAGE = fixture("test-3page.pdf"); +const HEIC_200x150 = fixture("test-200x150.heic"); + +// ─── SVG to Raster ────────────────────────────────────────────────── + +test.describe("SVG to Raster", () => { + test("convert SVG to PNG", async ({ request }) => { + const res = await request.post("/api/v1/tools/svg-to-raster", { + headers: { Authorization: `Bearer ${token}` }, + multipart: { + file: { name: "test.svg", mimeType: "image/svg+xml", buffer: SVG_100x100 }, + settings: JSON.stringify({ format: "png", width: 200 }), + }, + }); + expect(res.ok()).toBe(true); + const body = await res.json(); + expect(body.downloadUrl).toBeTruthy(); + expect(body.processedSize).toBeGreaterThan(0); + }); + + test("convert SVG to JPEG", async ({ request }) => { + const res = await request.post("/api/v1/tools/svg-to-raster", { + headers: { Authorization: `Bearer ${token}` }, + multipart: { + file: { name: "test.svg", mimeType: "image/svg+xml", buffer: SVG_100x100 }, + settings: JSON.stringify({ format: "jpg", width: 300 }), + }, + }); + expect(res.ok()).toBe(true); + const body = await res.json(); + expect(body.downloadUrl).toBeTruthy(); + }); + + test("convert SVG to WebP", async ({ request }) => { + const res = await request.post("/api/v1/tools/svg-to-raster", { + headers: { Authorization: `Bearer ${token}` }, + multipart: { + file: { name: "test.svg", mimeType: "image/svg+xml", buffer: SVG_100x100 }, + settings: JSON.stringify({ format: "webp", width: 150 }), + }, + }); + expect(res.ok()).toBe(true); + const body = await res.json(); + expect(body.downloadUrl).toBeTruthy(); + }); + + test("convert complex SVG from fixtures", async ({ request }) => { + const svgLogo = contentFixture("svg-logo.svg"); + const res = await request.post("/api/v1/tools/svg-to-raster", { + headers: { Authorization: `Bearer ${token}` }, + multipart: { + file: { name: "logo.svg", mimeType: "image/svg+xml", buffer: svgLogo }, + settings: JSON.stringify({ format: "png", width: 512 }), + }, + }); + expect(res.ok()).toBe(true); + const body = await res.json(); + expect(body.downloadUrl).toBeTruthy(); + }); + + test("convert large format SVG fixture", async ({ request }) => { + const svgSample = formatFixture("sample.svg"); + const res = await request.post("/api/v1/tools/svg-to-raster", { + headers: { Authorization: `Bearer ${token}` }, + multipart: { + file: { name: "sample.svg", mimeType: "image/svg+xml", buffer: svgSample }, + settings: JSON.stringify({ format: "png", width: 800 }), + }, + }); + expect(res.ok()).toBe(true); + const body = await res.json(); + expect(body.downloadUrl).toBeTruthy(); + }); +}); + +// ─── Vectorize ────────────────────────────────────────────────────── + +test.describe("Vectorize", () => { + test("vectorize PNG to SVG", async ({ request }) => { + const res = await request.post("/api/v1/tools/vectorize", { + headers: { Authorization: `Bearer ${token}` }, + multipart: { + file: { name: "test.png", mimeType: "image/png", buffer: PNG_200x150 }, + settings: JSON.stringify({}), + }, + }); + expect(res.ok()).toBe(true); + const body = await res.json(); + expect(body.downloadUrl).toBeTruthy(); + expect(body.processedSize).toBeGreaterThan(0); + }); + + test("vectorize JPEG to SVG", async ({ request }) => { + const res = await request.post("/api/v1/tools/vectorize", { + headers: { Authorization: `Bearer ${token}` }, + multipart: { + file: { name: "test.jpg", mimeType: "image/jpeg", buffer: JPG_100x100 }, + settings: JSON.stringify({}), + }, + }); + expect(res.ok()).toBe(true); + const body = await res.json(); + expect(body.downloadUrl).toBeTruthy(); + }); + + test("vectorized output is an SVG file", async ({ request }) => { + const res = await request.post("/api/v1/tools/vectorize", { + headers: { Authorization: `Bearer ${token}` }, + multipart: { + file: { name: "test.png", mimeType: "image/png", buffer: PNG_200x150 }, + settings: JSON.stringify({}), + }, + }); + expect(res.ok()).toBe(true); + const body = await res.json(); + expect(body.downloadUrl).toContain(".svg"); + }); +}); + +// ─── GIF Tools ────────────────────────────────────────────────────── + +test.describe("GIF Tools", () => { + test("process animated GIF", async ({ request }) => { + const res = await request.post("/api/v1/tools/gif-tools", { + headers: { Authorization: `Bearer ${token}` }, + multipart: { + file: { name: "animated.gif", mimeType: "image/gif", buffer: ANIMATED_GIF }, + settings: JSON.stringify({}), + }, + }); + // GIF tools may return 200 or may need specific settings + // Accept both success and settings-related error + if (res.ok()) { + const body = await res.json(); + expect(body.downloadUrl || body.frames).toBeTruthy(); + } else { + const body = await res.json(); + expect(body.error).toBeDefined(); + expect(typeof body.error).toBe("string"); + } + }); + + test("process GIF from formats fixture", async ({ request }) => { + const gifSample = formatFixture("sample.gif"); + const res = await request.post("/api/v1/tools/gif-tools", { + headers: { Authorization: `Bearer ${token}` }, + multipart: { + file: { name: "sample.gif", mimeType: "image/gif", buffer: gifSample }, + settings: JSON.stringify({}), + }, + }); + if (res.ok()) { + const body = await res.json(); + expect(body.downloadUrl || body.frames).toBeTruthy(); + } else { + const body = await res.json(); + expect(body.error).toBeDefined(); + } + }); + + test("process animated Simpsons GIF", async ({ request }) => { + const animGif = contentFixture("animated-simpsons.gif"); + const res = await request.post("/api/v1/tools/gif-tools", { + headers: { Authorization: `Bearer ${token}` }, + multipart: { + file: { name: "animated-simpsons.gif", mimeType: "image/gif", buffer: animGif }, + settings: JSON.stringify({}), + }, + }); + if (res.ok()) { + const body = await res.json(); + expect(body.downloadUrl || body.frames).toBeTruthy(); + } else { + const body = await res.json(); + expect(body.error).toBeDefined(); + } + }); +}); + +// ─── PDF to Image ─────────────────────────────────────────────────── + +test.describe("PDF to Image", () => { + test("convert first page of PDF to PNG", async ({ request }) => { + const res = await request.post("/api/v1/tools/pdf-to-image", { + headers: { Authorization: `Bearer ${token}` }, + multipart: { + file: { name: "test.pdf", mimeType: "application/pdf", buffer: PDF_3PAGE }, + settings: JSON.stringify({ format: "png", dpi: 150, pages: "1" }), + }, + }); + expect(res.ok()).toBe(true); + const body = await res.json(); + // PDF-to-image may return downloadUrl or pages array + expect(body.downloadUrl || body.pages || body.jobId).toBeTruthy(); + }); + + test("convert all pages of PDF", async ({ request }) => { + const res = await request.post("/api/v1/tools/pdf-to-image", { + headers: { Authorization: `Bearer ${token}` }, + multipart: { + file: { name: "test.pdf", mimeType: "application/pdf", buffer: PDF_3PAGE }, + settings: JSON.stringify({ format: "png", dpi: 150, pages: "all" }), + }, + }); + expect(res.ok()).toBe(true); + const body = await res.json(); + expect(body.downloadUrl || body.pages || body.jobId).toBeTruthy(); + }); + + test("convert PDF to JPEG format", async ({ request }) => { + const res = await request.post("/api/v1/tools/pdf-to-image", { + headers: { Authorization: `Bearer ${token}` }, + multipart: { + file: { name: "test.pdf", mimeType: "application/pdf", buffer: PDF_3PAGE }, + settings: JSON.stringify({ format: "jpg", dpi: 72, pages: "1" }), + }, + }); + expect(res.ok()).toBe(true); + const body = await res.json(); + expect(body.downloadUrl || body.pages || body.jobId).toBeTruthy(); + }); + + test("convert specific page range", async ({ request }) => { + const res = await request.post("/api/v1/tools/pdf-to-image", { + headers: { Authorization: `Bearer ${token}` }, + multipart: { + file: { name: "test.pdf", mimeType: "application/pdf", buffer: PDF_3PAGE }, + settings: JSON.stringify({ format: "png", dpi: 150, pages: "1-2" }), + }, + }); + expect(res.ok()).toBe(true); + const body = await res.json(); + expect(body.downloadUrl || body.pages || body.jobId).toBeTruthy(); + }); + + test("convert PDF to WebP format", async ({ request }) => { + const res = await request.post("/api/v1/tools/pdf-to-image", { + headers: { Authorization: `Bearer ${token}` }, + multipart: { + file: { name: "test.pdf", mimeType: "application/pdf", buffer: PDF_3PAGE }, + settings: JSON.stringify({ format: "webp", dpi: 150, pages: "1" }), + }, + }); + expect(res.ok()).toBe(true); + }); + + test("reject out-of-range page number", async ({ request }) => { + const res = await request.post("/api/v1/tools/pdf-to-image", { + headers: { Authorization: `Bearer ${token}` }, + multipart: { + file: { name: "test.pdf", mimeType: "application/pdf", buffer: PDF_3PAGE }, + settings: JSON.stringify({ format: "png", dpi: 150, pages: "99" }), + }, + }); + expect(res.ok()).toBe(false); + const body = await res.json(); + expect(body.error).toBeDefined(); + }); +}); + +// ─── Image to PDF ─────────────────────────────────────────────────── + +test.describe("Image to PDF", () => { + test("convert single image to PDF via API", async ({ request }) => { + const jpg = readFileSync(join(FIXTURES, "test-100x100.jpg")); + const { body, contentType } = buildMultipart( + [{ name: "file", filename: "test.jpg", contentType: "image/jpeg", buffer: jpg }], + [{ name: "settings", value: JSON.stringify({ pageSize: "a4" }) }], + ); + const res = await request.post("/api/v1/tools/image-to-pdf", { + headers: { Authorization: `Bearer ${token}`, "Content-Type": contentType }, + data: body, + }); + expect(res.ok()).toBe(true); + const dlRes = await res.body(); + // PDF starts with %PDF magic bytes + expect(Buffer.from(dlRes).subarray(0, 4).toString()).toBe("%PDF"); + }); + + test("convert multiple images to multi-page PDF", async ({ request }) => { + const jpg = readFileSync(join(FIXTURES, "test-100x100.jpg")); + const png = readFileSync(join(FIXTURES, "test-200x150.png")); + const { body, contentType } = buildMultipart( + [ + { name: "file", filename: "a.jpg", contentType: "image/jpeg", buffer: jpg }, + { name: "file", filename: "b.png", contentType: "image/png", buffer: png }, + ], + [{ name: "settings", value: JSON.stringify({ pageSize: "a4" }) }], + ); + const res = await request.post("/api/v1/tools/image-to-pdf", { + headers: { Authorization: `Bearer ${token}`, "Content-Type": contentType }, + data: body, + }); + expect(res.ok()).toBe(true); + const dlRes = await res.body(); + expect(Buffer.from(dlRes).subarray(0, 4).toString()).toBe("%PDF"); + }); +}); + +// ─── Image to Base64 ──────────────────────────────────────────────── + +test.describe("Image to Base64", () => { + test("encode PNG to base64", async ({ request }) => { + const res = await request.post("/api/v1/tools/image-to-base64", { + headers: { Authorization: `Bearer ${token}` }, + multipart: { + file: { name: "test.png", mimeType: "image/png", buffer: PNG_200x150 }, + settings: JSON.stringify({}), + }, + }); + expect(res.ok()).toBe(true); + const body = await res.json(); + expect(body.results).toBeInstanceOf(Array); + expect(body.results.length).toBeGreaterThan(0); + + const result = body.results[0]; + expect(result.base64).toBeTruthy(); + expect(result.dataUri).toContain("data:image/png;base64,"); + expect(result.mimeType).toBe("image/png"); + expect(result.width).toBeGreaterThan(0); + expect(result.height).toBeGreaterThan(0); + expect(result.originalSize).toBeGreaterThan(0); + expect(result.encodedSize).toBeGreaterThan(0); + }); + + test("encode JPEG to base64", async ({ request }) => { + const res = await request.post("/api/v1/tools/image-to-base64", { + headers: { Authorization: `Bearer ${token}` }, + multipart: { + file: { name: "test.jpg", mimeType: "image/jpeg", buffer: JPG_100x100 }, + settings: JSON.stringify({}), + }, + }); + expect(res.ok()).toBe(true); + const body = await res.json(); + expect(body.results[0].base64).toBeTruthy(); + expect(body.results[0].mimeType).toContain("image/"); + }); + + test("encode with output format conversion", async ({ request }) => { + const res = await request.post("/api/v1/tools/image-to-base64", { + headers: { Authorization: `Bearer ${token}` }, + multipart: { + file: { name: "test.png", mimeType: "image/png", buffer: PNG_200x150 }, + settings: JSON.stringify({ outputFormat: "jpeg", quality: 50 }), + }, + }); + expect(res.ok()).toBe(true); + const body = await res.json(); + expect(body.results[0].mimeType).toBe("image/jpeg"); + expect(body.results[0].dataUri).toContain("data:image/jpeg;base64,"); + }); + + test("encode with maxWidth constraint", async ({ request }) => { + const res = await request.post("/api/v1/tools/image-to-base64", { + headers: { Authorization: `Bearer ${token}` }, + multipart: { + file: { name: "test.png", mimeType: "image/png", buffer: PNG_200x150 }, + settings: JSON.stringify({ maxWidth: 50 }), + }, + }); + expect(res.ok()).toBe(true); + const body = await res.json(); + expect(body.results[0].width).toBeLessThanOrEqual(50); + }); + + test("base64 overhead percent is calculated", async ({ request }) => { + const res = await request.post("/api/v1/tools/image-to-base64", { + headers: { Authorization: `Bearer ${token}` }, + multipart: { + file: { name: "test.png", mimeType: "image/png", buffer: PNG_200x150 }, + settings: JSON.stringify({}), + }, + }); + expect(res.ok()).toBe(true); + const body = await res.json(); + expect(typeof body.results[0].overheadPercent).toBe("number"); + // Base64 always adds ~33% overhead + expect(body.results[0].overheadPercent).toBeGreaterThan(0); + }); +}); + +// ─── Favicon ──────────────────────────────────────────────────────── + +test.describe("Favicon", () => { + test("generate favicon from PNG", async ({ request }) => { + const res = await request.post("/api/v1/tools/favicon", { + headers: { Authorization: `Bearer ${token}` }, + multipart: { + file: { name: "test.png", mimeType: "image/png", buffer: PNG_200x150 }, + settings: JSON.stringify({}), + }, + }); + expect(res.ok()).toBe(true); + // Favicon may return JSON with downloadUrl or binary ICO + const contentType = res.headers()["content-type"] ?? ""; + if (contentType.includes("application/json")) { + const body = await res.json(); + expect(body.downloadUrl).toBeTruthy(); + } else { + // Binary ICO response + const buffer = Buffer.from(await res.body()); + expect(buffer.length).toBeGreaterThan(0); + } + }); + + test("generate favicon from JPEG", async ({ request }) => { + const res = await request.post("/api/v1/tools/favicon", { + headers: { Authorization: `Bearer ${token}` }, + multipart: { + file: { name: "test.jpg", mimeType: "image/jpeg", buffer: JPG_100x100 }, + settings: JSON.stringify({}), + }, + }); + expect(res.ok()).toBe(true); + }); + + test("generate favicon from SVG", async ({ request }) => { + const svgLogo = contentFixture("svg-logo.svg"); + const res = await request.post("/api/v1/tools/favicon", { + headers: { Authorization: `Bearer ${token}` }, + multipart: { + file: { name: "logo.svg", mimeType: "image/svg+xml", buffer: svgLogo }, + settings: JSON.stringify({}), + }, + }); + expect(res.ok()).toBe(true); + }); +}); + +// ─── Convert Format Matrix ────────────────────────────────────────── + +test.describe("Convert format matrix", () => { + const formats = ["jpg", "png", "webp", "avif", "tiff", "gif"] as const; + + for (const targetFormat of formats) { + test(`PNG to ${targetFormat}`, async ({ request }) => { + const res = await request.post("/api/v1/tools/convert", { + headers: { Authorization: `Bearer ${token}` }, + multipart: { + file: { name: "test.png", mimeType: "image/png", buffer: PNG_200x150 }, + settings: JSON.stringify({ format: targetFormat }), + }, + }); + expect(res.ok()).toBe(true); + const body = await res.json(); + expect(body.downloadUrl).toContain(`.${targetFormat}`); + expect(body.processedSize).toBeGreaterThan(0); + }); + } + + test("HEIC to JPEG", async ({ request }) => { + const res = await request.post("/api/v1/tools/convert", { + headers: { Authorization: `Bearer ${token}` }, + multipart: { + file: { name: "test.heic", mimeType: "image/heic", buffer: HEIC_200x150 }, + settings: JSON.stringify({ format: "jpg" }), + }, + }); + expect(res.ok()).toBe(true); + const body = await res.json(); + expect(body.downloadUrl).toContain(".jpg"); + }); + + test("HEIC to WebP", async ({ request }) => { + const res = await request.post("/api/v1/tools/convert", { + headers: { Authorization: `Bearer ${token}` }, + multipart: { + file: { name: "test.heic", mimeType: "image/heic", buffer: HEIC_200x150 }, + settings: JSON.stringify({ format: "webp" }), + }, + }); + expect(res.ok()).toBe(true); + const body = await res.json(); + expect(body.downloadUrl).toContain(".webp"); + }); + + test("WebP to PNG", async ({ request }) => { + const webp = fixture("test-50x50.webp"); + const res = await request.post("/api/v1/tools/convert", { + headers: { Authorization: `Bearer ${token}` }, + multipart: { + file: { name: "test.webp", mimeType: "image/webp", buffer: webp }, + settings: JSON.stringify({ format: "png" }), + }, + }); + expect(res.ok()).toBe(true); + const body = await res.json(); + expect(body.downloadUrl).toContain(".png"); + }); + + test("BMP to PNG", async ({ request }) => { + const bmp = formatFixture("sample.bmp"); + const res = await request.post("/api/v1/tools/convert", { + headers: { Authorization: `Bearer ${token}` }, + multipart: { + file: { name: "sample.bmp", mimeType: "image/bmp", buffer: bmp }, + settings: JSON.stringify({ format: "png" }), + }, + }); + // BMP decode requires CLI decoder which may not be available + if (res.ok()) { + const body = await res.json(); + expect(body.downloadUrl).toContain(".png"); + } else { + expect([400, 422]).toContain(res.status()); + } + }); +}); diff --git a/tests/e2e-docker/creative-tools.spec.ts b/tests/e2e-docker/creative-tools.spec.ts new file mode 100644 index 00000000..f2e4e8e5 --- /dev/null +++ b/tests/e2e-docker/creative-tools.spec.ts @@ -0,0 +1,587 @@ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { expect, type Page, test } from "@playwright/test"; + +// ─── Creative Tools ───────────────────────────────────────────────── +// Tests for: collage, stitch, split, border, compose, watermark-text, +// watermark-image, text-overlay, content-aware-resize +// These tools handle multi-image operations, overlays, and layout. + +const FIXTURES = join(process.cwd(), "tests", "fixtures"); + +let token: string; + +test.beforeAll(async ({ request }) => { + const res = await request.post("/api/auth/login", { + data: { username: "admin", password: "admin" }, + }); + const body = await res.json(); + token = body.token; +}); + +function fixture(name: string): Buffer { + return readFileSync(join(FIXTURES, name)); +} + +const PNG_200x150 = fixture("test-200x150.png"); +const JPG_100x100 = fixture("test-100x100.jpg"); +const WEBP_50x50 = fixture("test-50x50.webp"); + +/** + * Build a raw multipart/form-data body. Playwright's `multipart` option + * does not support arrays for the same field name, so multi-file uploads + * must be assembled manually. + */ +function buildMultipart( + files: Array<{ name: string; filename: string; contentType: string; buffer: Buffer }>, + fields: Array<{ name: string; value: string }>, +): { body: Buffer; contentType: string } { + const boundary = `----PlaywrightBoundary${Date.now()}`; + const parts: Buffer[] = []; + for (const file of files) { + parts.push( + Buffer.from( + `--${boundary}\r\nContent-Disposition: form-data; name="${file.name}"; filename="${file.filename}"\r\nContent-Type: ${file.contentType}\r\n\r\n`, + ), + ); + parts.push(file.buffer); + parts.push(Buffer.from("\r\n")); + } + for (const field of fields) { + parts.push( + Buffer.from( + `--${boundary}\r\nContent-Disposition: form-data; name="${field.name}"\r\n\r\n${field.value}\r\n`, + ), + ); + } + parts.push(Buffer.from(`--${boundary}--\r\n`)); + return { + body: Buffer.concat(parts), + contentType: `multipart/form-data; boundary=${boundary}`, + }; +} + +async function uploadFiles(page: Page, filePaths: string[]): Promise { + const fileChooserPromise = page.waitForEvent("filechooser"); + const dropzone = page.locator("[class*='border-dashed']").first(); + await dropzone.click(); + const fileChooser = await fileChooserPromise; + await fileChooser.setFiles(filePaths); + await page.waitForTimeout(3000); +} + +async function waitForProcessingDone(page: Page, timeoutMs = 120_000): Promise { + try { + const spinner = page.locator("[class*='animate-spin']"); + if (await spinner.isVisible({ timeout: 3000 })) { + await spinner.waitFor({ state: "hidden", timeout: timeoutMs }); + } + } catch { + // No spinner — processing may have been instant + } + await page.waitForTimeout(500); +} + +// ─── Collage ──────────────────────────────────────────────────────── + +test.describe("Collage", () => { + test("create 2-image collage via API", async ({ request }) => { + const jpg = readFileSync(join(FIXTURES, "test-100x100.jpg")); + const png = readFileSync(join(FIXTURES, "test-200x150.png")); + const { body, contentType } = buildMultipart( + [ + { name: "file", filename: "a.jpg", contentType: "image/jpeg", buffer: jpg }, + { name: "file", filename: "b.png", contentType: "image/png", buffer: png }, + ], + [ + { + name: "settings", + value: JSON.stringify({ + templateId: "2-h-equal", + width: 400, + height: 200, + format: "png", + }), + }, + ], + ); + const res = await request.post("/api/v1/tools/collage", { + headers: { Authorization: `Bearer ${token}`, "Content-Type": contentType }, + data: body, + }); + expect(res.ok()).toBe(true); + const json = await res.json(); + expect(json.downloadUrl).toBeTruthy(); + expect(json.processedSize).toBeGreaterThan(0); + }); + + test("collage API with 2 images", async ({ request }) => { + const { body, contentType } = buildMultipart( + [ + { name: "file", filename: "a.png", contentType: "image/png", buffer: PNG_200x150 }, + { name: "file", filename: "b.jpg", contentType: "image/jpeg", buffer: JPG_100x100 }, + ], + [ + { + name: "settings", + value: JSON.stringify({ + templateId: "2-h-equal", + gap: 10, + backgroundColor: "#FFFFFF", + outputFormat: "png", + }), + }, + ], + ); + const res = await request.post("/api/v1/tools/collage", { + headers: { + Authorization: `Bearer ${token}`, + "Content-Type": contentType, + }, + data: body, + }); + expect(res.ok()).toBe(true); + const json = await res.json(); + expect(json.downloadUrl).toBeTruthy(); + }); +}); + +// ─── Stitch ───────────────────────────────────────────────────────── + +test.describe("Stitch", () => { + test("stitch 2 images horizontally", async ({ request }) => { + const { body, contentType } = buildMultipart( + [ + { name: "file", filename: "a.png", contentType: "image/png", buffer: PNG_200x150 }, + { name: "file", filename: "b.jpg", contentType: "image/jpeg", buffer: JPG_100x100 }, + ], + [{ name: "settings", value: JSON.stringify({ direction: "horizontal", gap: 0 }) }], + ); + const res = await request.post("/api/v1/tools/stitch", { + headers: { Authorization: `Bearer ${token}`, "Content-Type": contentType }, + data: body, + }); + expect(res.ok()).toBe(true); + const json = await res.json(); + expect(json.downloadUrl).toBeTruthy(); + expect(json.processedSize).toBeGreaterThan(0); + }); + + test("stitch 2 images vertically", async ({ request }) => { + const { body, contentType } = buildMultipart( + [ + { name: "file", filename: "a.png", contentType: "image/png", buffer: PNG_200x150 }, + { name: "file", filename: "b.jpg", contentType: "image/jpeg", buffer: JPG_100x100 }, + ], + [{ name: "settings", value: JSON.stringify({ direction: "vertical", gap: 0 }) }], + ); + const res = await request.post("/api/v1/tools/stitch", { + headers: { Authorization: `Bearer ${token}`, "Content-Type": contentType }, + data: body, + }); + expect(res.ok()).toBe(true); + const json = await res.json(); + expect(json.downloadUrl).toBeTruthy(); + }); + + test("stitch with gap and background color", async ({ request }) => { + const { body, contentType } = buildMultipart( + [ + { name: "file", filename: "a.png", contentType: "image/png", buffer: PNG_200x150 }, + { name: "file", filename: "b.jpg", contentType: "image/jpeg", buffer: JPG_100x100 }, + ], + [ + { + name: "settings", + value: JSON.stringify({ + direction: "horizontal", + gap: 20, + backgroundColor: "#FF0000", + }), + }, + ], + ); + const res = await request.post("/api/v1/tools/stitch", { + headers: { Authorization: `Bearer ${token}`, "Content-Type": contentType }, + data: body, + }); + expect(res.ok()).toBe(true); + const json = await res.json(); + expect(json.downloadUrl).toBeTruthy(); + }); + + test("stitch 3 images in grid mode", async ({ request }) => { + const { body, contentType } = buildMultipart( + [ + { name: "file", filename: "a.png", contentType: "image/png", buffer: PNG_200x150 }, + { name: "file", filename: "b.jpg", contentType: "image/jpeg", buffer: JPG_100x100 }, + { name: "file", filename: "c.webp", contentType: "image/webp", buffer: WEBP_50x50 }, + ], + [{ name: "settings", value: JSON.stringify({ direction: "grid", gridColumns: 2 }) }], + ); + const res = await request.post("/api/v1/tools/stitch", { + headers: { Authorization: `Bearer ${token}`, "Content-Type": contentType }, + data: body, + }); + expect(res.ok()).toBe(true); + const json = await res.json(); + expect(json.downloadUrl).toBeTruthy(); + }); + + test("stitch rejects single file", async ({ request }) => { + const { body, contentType } = buildMultipart( + [{ name: "file", filename: "a.png", contentType: "image/png", buffer: PNG_200x150 }], + [{ name: "settings", value: JSON.stringify({ direction: "horizontal" }) }], + ); + const res = await request.post("/api/v1/tools/stitch", { + headers: { Authorization: `Bearer ${token}`, "Content-Type": contentType }, + data: body, + }); + expect(res.ok()).toBe(false); + const json = await res.json(); + expect(json.error).toBeDefined(); + }); +}); + +// ─── Split ────────────────────────────────────────────────────────── + +test.describe("Split", () => { + test("split image into 2x2 tiles via API", async ({ request }) => { + const res = await request.post("/api/v1/tools/split", { + headers: { Authorization: `Bearer ${token}` }, + multipart: { + file: { name: "test.png", mimeType: "image/png", buffer: PNG_200x150 }, + settings: JSON.stringify({ columns: 2, rows: 2 }), + }, + }); + expect(res.ok()).toBe(true); + const body = await res.json(); + expect(body.downloadUrl).toBeTruthy(); + expect(body.processedSize).toBeGreaterThan(0); + }); + + test("split image into 3x3 tiles via API", async ({ request }) => { + const res = await request.post("/api/v1/tools/split", { + headers: { Authorization: `Bearer ${token}` }, + multipart: { + file: { name: "test.png", mimeType: "image/png", buffer: PNG_200x150 }, + settings: JSON.stringify({ columns: 3, rows: 3 }), + }, + }); + expect(res.ok()).toBe(true); + const body = await res.json(); + expect(body.downloadUrl).toBeTruthy(); + expect(body.processedSize).toBeGreaterThan(0); + }); +}); + +// ─── Border ───────────────────────────────────────────────────────── + +test.describe("Border", () => { + test("add solid color border", async ({ request }) => { + const res = await request.post("/api/v1/tools/border", { + headers: { Authorization: `Bearer ${token}` }, + multipart: { + file: { name: "test.png", mimeType: "image/png", buffer: PNG_200x150 }, + settings: JSON.stringify({ size: 20, color: "#ff0000" }), + }, + }); + expect(res.ok()).toBe(true); + const body = await res.json(); + expect(body.downloadUrl).toBeTruthy(); + // Border adds pixels, so output should be larger + expect(body.processedSize).toBeGreaterThan(0); + }); + + test("add thin border", async ({ request }) => { + const res = await request.post("/api/v1/tools/border", { + headers: { Authorization: `Bearer ${token}` }, + multipart: { + file: { name: "test.jpg", mimeType: "image/jpeg", buffer: JPG_100x100 }, + settings: JSON.stringify({ size: 1, color: "#000000" }), + }, + }); + expect(res.ok()).toBe(true); + const body = await res.json(); + expect(body.downloadUrl).toBeTruthy(); + }); + + test("add wide white border", async ({ request }) => { + const res = await request.post("/api/v1/tools/border", { + headers: { Authorization: `Bearer ${token}` }, + multipart: { + file: { name: "test.png", mimeType: "image/png", buffer: PNG_200x150 }, + settings: JSON.stringify({ size: 50, color: "#ffffff" }), + }, + }); + expect(res.ok()).toBe(true); + const body = await res.json(); + expect(body.downloadUrl).toBeTruthy(); + }); +}); + +// ─── Compose ──────────────────────────────────────────────────────── + +test.describe("Compose", () => { + test("compose base + overlay via API", async ({ request }) => { + const { body, contentType } = buildMultipart( + [ + { name: "file", filename: "base.png", contentType: "image/png", buffer: PNG_200x150 }, + { + name: "overlay", + filename: "overlay.jpg", + contentType: "image/jpeg", + buffer: JPG_100x100, + }, + ], + [{ name: "settings", value: JSON.stringify({ x: 10, y: 10, opacity: 80 }) }], + ); + const res = await request.post("/api/v1/tools/compose", { + headers: { Authorization: `Bearer ${token}`, "Content-Type": contentType }, + data: body, + }); + expect(res.ok()).toBe(true); + const json = await res.json(); + expect(json.downloadUrl).toBeTruthy(); + expect(json.processedSize).toBeGreaterThan(0); + }); +}); + +// ─── Watermark Text ───────────────────────────────────────────────── + +test.describe("Watermark Text", () => { + test("add center watermark", async ({ request }) => { + const res = await request.post("/api/v1/tools/watermark-text", { + headers: { Authorization: `Bearer ${token}` }, + multipart: { + file: { name: "test.png", mimeType: "image/png", buffer: PNG_200x150 }, + settings: JSON.stringify({ + text: "SAMPLE", + fontSize: 48, + color: "#ff0000", + opacity: 50, + position: "center", + }), + }, + }); + expect(res.ok()).toBe(true); + const body = await res.json(); + expect(body.downloadUrl).toBeTruthy(); + expect(body.processedSize).toBeGreaterThan(0); + }); + + test("add tiled watermark", async ({ request }) => { + const res = await request.post("/api/v1/tools/watermark-text", { + headers: { Authorization: `Bearer ${token}` }, + multipart: { + file: { name: "test.jpg", mimeType: "image/jpeg", buffer: JPG_100x100 }, + settings: JSON.stringify({ + text: "CONFIDENTIAL", + fontSize: 24, + color: "#808080", + opacity: 30, + position: "tiled", + }), + }, + }); + expect(res.ok()).toBe(true); + const body = await res.json(); + expect(body.downloadUrl).toBeTruthy(); + }); + + test("add corner watermark positions", async ({ request }) => { + const positions = ["top-left", "top-right", "bottom-left", "bottom-right"] as const; + for (const position of positions) { + const res = await request.post("/api/v1/tools/watermark-text", { + headers: { Authorization: `Bearer ${token}` }, + multipart: { + file: { name: "test.png", mimeType: "image/png", buffer: PNG_200x150 }, + settings: JSON.stringify({ + text: "WM", + fontSize: 16, + color: "#000000", + opacity: 80, + position, + }), + }, + }); + expect(res.ok(), `watermark at ${position} should succeed`).toBe(true); + const body = await res.json(); + expect(body.downloadUrl).toBeTruthy(); + } + }); + + test("add rotated watermark", async ({ request }) => { + const res = await request.post("/api/v1/tools/watermark-text", { + headers: { Authorization: `Bearer ${token}` }, + multipart: { + file: { name: "test.png", mimeType: "image/png", buffer: PNG_200x150 }, + settings: JSON.stringify({ + text: "DRAFT", + fontSize: 36, + color: "#ff0000", + opacity: 40, + position: "center", + rotation: -45, + }), + }, + }); + expect(res.ok()).toBe(true); + const body = await res.json(); + expect(body.downloadUrl).toBeTruthy(); + }); +}); + +// ─── Watermark Image ──────────────────────────────────────────────── + +test.describe("Watermark Image", () => { + test("add image watermark via API", async ({ request }) => { + const { body, contentType } = buildMultipart( + [ + { name: "file", filename: "main.png", contentType: "image/png", buffer: PNG_200x150 }, + { + name: "watermark", + filename: "wm.jpg", + contentType: "image/jpeg", + buffer: JPG_100x100, + }, + ], + [ + { + name: "settings", + value: JSON.stringify({ position: "bottom-right", opacity: 50, scale: 25 }), + }, + ], + ); + const res = await request.post("/api/v1/tools/watermark-image", { + headers: { Authorization: `Bearer ${token}`, "Content-Type": contentType }, + data: body, + }); + expect(res.ok()).toBe(true); + const json = await res.json(); + expect(json.downloadUrl).toBeTruthy(); + expect(json.processedSize).toBeGreaterThan(0); + }); +}); + +// ─── Text Overlay ─────────────────────────────────────────────────── + +test.describe("Text Overlay", () => { + test("add text overlay at center", async ({ request }) => { + const res = await request.post("/api/v1/tools/text-overlay", { + headers: { Authorization: `Bearer ${token}` }, + multipart: { + file: { name: "test.png", mimeType: "image/png", buffer: PNG_200x150 }, + settings: JSON.stringify({ + text: "Hello World", + fontSize: 48, + color: "#FFFFFF", + position: "center", + }), + }, + }); + expect(res.ok()).toBe(true); + const body = await res.json(); + expect(body.downloadUrl).toBeTruthy(); + expect(body.processedSize).toBeGreaterThan(0); + }); + + test("add text overlay at top", async ({ request }) => { + const res = await request.post("/api/v1/tools/text-overlay", { + headers: { Authorization: `Bearer ${token}` }, + multipart: { + file: { name: "test.jpg", mimeType: "image/jpeg", buffer: JPG_100x100 }, + settings: JSON.stringify({ + text: "Title Text", + fontSize: 24, + color: "#FF0000", + position: "top", + }), + }, + }); + expect(res.ok()).toBe(true); + const body = await res.json(); + expect(body.downloadUrl).toBeTruthy(); + }); + + test("add text overlay at bottom with background box", async ({ request }) => { + const res = await request.post("/api/v1/tools/text-overlay", { + headers: { Authorization: `Bearer ${token}` }, + multipart: { + file: { name: "test.png", mimeType: "image/png", buffer: PNG_200x150 }, + settings: JSON.stringify({ + text: "Caption", + fontSize: 36, + color: "#FFFFFF", + position: "bottom", + backgroundBox: true, + backgroundColor: "#000000", + }), + }, + }); + expect(res.ok()).toBe(true); + const body = await res.json(); + expect(body.downloadUrl).toBeTruthy(); + }); + + test("add text overlay with shadow", async ({ request }) => { + const res = await request.post("/api/v1/tools/text-overlay", { + headers: { Authorization: `Bearer ${token}` }, + multipart: { + file: { name: "test.png", mimeType: "image/png", buffer: PNG_200x150 }, + settings: JSON.stringify({ + text: "Shadow Text", + fontSize: 40, + color: "#FFFFFF", + position: "center", + shadow: true, + }), + }, + }); + expect(res.ok()).toBe(true); + const body = await res.json(); + expect(body.downloadUrl).toBeTruthy(); + }); +}); + +// ─── Content-Aware Resize ─────────────────────────────────────────── + +test.describe("Content-Aware Resize", () => { + test("content-aware resize to smaller width", async ({ request }) => { + const res = await request.post("/api/v1/tools/content-aware-resize", { + headers: { Authorization: `Bearer ${token}` }, + multipart: { + file: { name: "test.png", mimeType: "image/png", buffer: PNG_200x150 }, + settings: JSON.stringify({ width: 150, height: 150 }), + }, + }); + // Content-aware resize uses caire binary — may return 501 if not installed + if (res.status() === 501) { + const body = await res.json(); + expect(body.code).toBe("FEATURE_NOT_INSTALLED"); + } else { + expect(res.ok()).toBe(true); + const body = await res.json(); + expect(body.downloadUrl).toBeTruthy(); + expect(body.processedSize).toBeGreaterThan(0); + } + }); + + test("content-aware resize JPEG", async ({ request }) => { + const res = await request.post("/api/v1/tools/content-aware-resize", { + headers: { Authorization: `Bearer ${token}` }, + multipart: { + file: { name: "test.jpg", mimeType: "image/jpeg", buffer: JPG_100x100 }, + settings: JSON.stringify({ width: 80, height: 80 }), + }, + }); + if (res.status() === 501) { + const body = await res.json(); + expect(body.code).toBe("FEATURE_NOT_INSTALLED"); + } else { + expect(res.ok()).toBe(true); + const body = await res.json(); + expect(body.downloadUrl).toBeTruthy(); + } + }); +}); diff --git a/tests/e2e-docker/essential-tools.spec.ts b/tests/e2e-docker/essential-tools.spec.ts new file mode 100644 index 00000000..b1461109 --- /dev/null +++ b/tests/e2e-docker/essential-tools.spec.ts @@ -0,0 +1,458 @@ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { expect, test } from "@playwright/test"; + +// ─── Essential Tools ──────────────────────────────────────────────── +// Tests for: resize, crop, rotate, convert, compress +// These are the core Sharp-based image manipulation tools. + +const FIXTURES = join(process.cwd(), "tests", "fixtures"); +const FORMATS = join(FIXTURES, "formats"); + +let token: string; + +test.beforeAll(async ({ request }) => { + const res = await request.post("/api/auth/login", { + data: { username: "admin", password: "admin" }, + }); + const body = await res.json(); + token = body.token; +}); + +function fixture(name: string): Buffer { + return readFileSync(join(FIXTURES, name)); +} + +function formatFixture(name: string): Buffer { + return readFileSync(join(FORMATS, name)); +} + +/** 200x150 PNG for dimension-sensitive tests. */ +const PNG_200x150 = fixture("test-200x150.png"); + +/** 100x100 JPEG for format variety. */ +const JPG_100x100 = fixture("test-100x100.jpg"); + +/** HEIC image for format decode testing. */ +const HEIC_200x150 = fixture("test-200x150.heic"); + +// ─── Resize ────────────────────────────────────────────────────────── + +test.describe("Resize", () => { + test("resize to explicit width and height", async ({ request }) => { + const res = await request.post("/api/v1/tools/resize", { + headers: { Authorization: `Bearer ${token}` }, + multipart: { + file: { name: "test.png", mimeType: "image/png", buffer: PNG_200x150 }, + settings: JSON.stringify({ width: 100, height: 75, fit: "fill" }), + }, + }); + expect(res.ok()).toBe(true); + const body = await res.json(); + expect(body.downloadUrl).toBeTruthy(); + expect(body.processedSize).toBeGreaterThan(0); + }); + + test("resize with only width preserves aspect ratio", async ({ request }) => { + const res = await request.post("/api/v1/tools/resize", { + headers: { Authorization: `Bearer ${token}` }, + multipart: { + file: { name: "test.png", mimeType: "image/png", buffer: PNG_200x150 }, + settings: JSON.stringify({ width: 100, fit: "contain" }), + }, + }); + expect(res.ok()).toBe(true); + const body = await res.json(); + expect(body.downloadUrl).toBeTruthy(); + expect(body.processedSize).toBeGreaterThan(0); + }); + + test("resize with only height preserves aspect ratio", async ({ request }) => { + const res = await request.post("/api/v1/tools/resize", { + headers: { Authorization: `Bearer ${token}` }, + multipart: { + file: { name: "test.png", mimeType: "image/png", buffer: PNG_200x150 }, + settings: JSON.stringify({ height: 50, fit: "contain" }), + }, + }); + expect(res.ok()).toBe(true); + const body = await res.json(); + expect(body.downloadUrl).toBeTruthy(); + }); + + test("resize with fit=cover", async ({ request }) => { + const res = await request.post("/api/v1/tools/resize", { + headers: { Authorization: `Bearer ${token}` }, + multipart: { + file: { name: "test.png", mimeType: "image/png", buffer: PNG_200x150 }, + settings: JSON.stringify({ width: 80, height: 80, fit: "cover" }), + }, + }); + expect(res.ok()).toBe(true); + const body = await res.json(); + expect(body.downloadUrl).toBeTruthy(); + }); + + test("resize with withoutEnlargement prevents upscaling", async ({ request }) => { + const res = await request.post("/api/v1/tools/resize", { + headers: { Authorization: `Bearer ${token}` }, + multipart: { + file: { name: "test.png", mimeType: "image/png", buffer: PNG_200x150 }, + settings: JSON.stringify({ width: 400, withoutEnlargement: true }), + }, + }); + expect(res.ok()).toBe(true); + const body = await res.json(); + expect(body.downloadUrl).toBeTruthy(); + expect(body.processedSize).toBeGreaterThan(0); + }); + + test("resize HEIC image succeeds", async ({ request }) => { + const res = await request.post("/api/v1/tools/resize", { + headers: { Authorization: `Bearer ${token}` }, + multipart: { + file: { name: "test.heic", mimeType: "image/heic", buffer: HEIC_200x150 }, + settings: JSON.stringify({ width: 100, fit: "contain" }), + }, + }); + expect(res.ok()).toBe(true); + const body = await res.json(); + expect(body.downloadUrl).toBeTruthy(); + }); + + test("resize rejects invalid width=0", async ({ request }) => { + const res = await request.post("/api/v1/tools/resize", { + headers: { Authorization: `Bearer ${token}` }, + multipart: { + file: { name: "test.png", mimeType: "image/png", buffer: PNG_200x150 }, + settings: JSON.stringify({ width: 0 }), + }, + }); + expect(res.ok()).toBe(false); + const body = await res.json(); + expect(body.error).toBeDefined(); + }); + + test("resize rejects request with no file", async ({ request }) => { + const res = await request.post("/api/v1/tools/resize", { + headers: { Authorization: `Bearer ${token}` }, + multipart: { + settings: JSON.stringify({ width: 100 }), + }, + }); + expect(res.ok()).toBe(false); + }); +}); + +// ─── Crop ──────────────────────────────────────────────────────────── + +test.describe("Crop", () => { + test("crop to specific region", async ({ request }) => { + const res = await request.post("/api/v1/tools/crop", { + headers: { Authorization: `Bearer ${token}` }, + multipart: { + file: { name: "test.png", mimeType: "image/png", buffer: PNG_200x150 }, + settings: JSON.stringify({ left: 10, top: 10, width: 100, height: 75 }), + }, + }); + expect(res.ok()).toBe(true); + const body = await res.json(); + expect(body.downloadUrl).toBeTruthy(); + expect(body.processedSize).toBeGreaterThan(0); + // Cropped image should be smaller than original + expect(body.processedSize).toBeLessThan(body.originalSize); + }); + + test("crop with zero offset extracts from corner", async ({ request }) => { + const res = await request.post("/api/v1/tools/crop", { + headers: { Authorization: `Bearer ${token}` }, + multipart: { + file: { name: "test.png", mimeType: "image/png", buffer: PNG_200x150 }, + settings: JSON.stringify({ left: 0, top: 0, width: 50, height: 50 }), + }, + }); + expect(res.ok()).toBe(true); + const body = await res.json(); + expect(body.downloadUrl).toBeTruthy(); + }); + + test("crop JPEG image", async ({ request }) => { + const res = await request.post("/api/v1/tools/crop", { + headers: { Authorization: `Bearer ${token}` }, + multipart: { + file: { name: "test.jpg", mimeType: "image/jpeg", buffer: JPG_100x100 }, + settings: JSON.stringify({ left: 10, top: 10, width: 50, height: 50 }), + }, + }); + expect(res.ok()).toBe(true); + const body = await res.json(); + expect(body.downloadUrl).toBeTruthy(); + }); +}); + +// ─── Rotate ────────────────────────────────────────────────────────── + +test.describe("Rotate", () => { + test("rotate 90 degrees", async ({ request }) => { + const res = await request.post("/api/v1/tools/rotate", { + headers: { Authorization: `Bearer ${token}` }, + multipart: { + file: { name: "test.png", mimeType: "image/png", buffer: PNG_200x150 }, + settings: JSON.stringify({ angle: 90 }), + }, + }); + expect(res.ok()).toBe(true); + const body = await res.json(); + expect(body.downloadUrl).toBeTruthy(); + expect(body.processedSize).toBeGreaterThan(0); + }); + + test("rotate 180 degrees", async ({ request }) => { + const res = await request.post("/api/v1/tools/rotate", { + headers: { Authorization: `Bearer ${token}` }, + multipart: { + file: { name: "test.png", mimeType: "image/png", buffer: PNG_200x150 }, + settings: JSON.stringify({ angle: 180 }), + }, + }); + expect(res.ok()).toBe(true); + const body = await res.json(); + expect(body.downloadUrl).toBeTruthy(); + }); + + test("rotate 270 degrees", async ({ request }) => { + const res = await request.post("/api/v1/tools/rotate", { + headers: { Authorization: `Bearer ${token}` }, + multipart: { + file: { name: "test.png", mimeType: "image/png", buffer: PNG_200x150 }, + settings: JSON.stringify({ angle: 270 }), + }, + }); + expect(res.ok()).toBe(true); + const body = await res.json(); + expect(body.downloadUrl).toBeTruthy(); + }); + + test("rotate 45 degrees with background fill", async ({ request }) => { + const res = await request.post("/api/v1/tools/rotate", { + headers: { Authorization: `Bearer ${token}` }, + multipart: { + file: { name: "test.png", mimeType: "image/png", buffer: PNG_200x150 }, + settings: JSON.stringify({ angle: 45, background: "#ffffff" }), + }, + }); + expect(res.ok()).toBe(true); + const body = await res.json(); + expect(body.downloadUrl).toBeTruthy(); + // 45-degree rotation expands the canvas, so output should be larger + expect(body.processedSize).toBeGreaterThan(0); + }); + + test("rotate 0 degrees returns image unchanged", async ({ request }) => { + const res = await request.post("/api/v1/tools/rotate", { + headers: { Authorization: `Bearer ${token}` }, + multipart: { + file: { name: "test.png", mimeType: "image/png", buffer: PNG_200x150 }, + settings: JSON.stringify({ angle: 0 }), + }, + }); + expect(res.ok()).toBe(true); + const body = await res.json(); + expect(body.downloadUrl).toBeTruthy(); + }); +}); + +// ─── Convert ───────────────────────────────────────────────────────── + +test.describe("Convert", () => { + test("convert PNG to JPEG", async ({ request }) => { + const res = await request.post("/api/v1/tools/convert", { + headers: { Authorization: `Bearer ${token}` }, + multipart: { + file: { name: "test.png", mimeType: "image/png", buffer: PNG_200x150 }, + settings: JSON.stringify({ format: "jpg", quality: 85 }), + }, + }); + expect(res.ok()).toBe(true); + const body = await res.json(); + expect(body.downloadUrl).toContain(".jpg"); + expect(body.processedSize).toBeGreaterThan(0); + }); + + test("convert PNG to WebP", async ({ request }) => { + const res = await request.post("/api/v1/tools/convert", { + headers: { Authorization: `Bearer ${token}` }, + multipart: { + file: { name: "test.png", mimeType: "image/png", buffer: PNG_200x150 }, + settings: JSON.stringify({ format: "webp" }), + }, + }); + expect(res.ok()).toBe(true); + const body = await res.json(); + expect(body.downloadUrl).toContain(".webp"); + }); + + test("convert PNG to AVIF", async ({ request }) => { + const res = await request.post("/api/v1/tools/convert", { + headers: { Authorization: `Bearer ${token}` }, + multipart: { + file: { name: "test.png", mimeType: "image/png", buffer: PNG_200x150 }, + settings: JSON.stringify({ format: "avif" }), + }, + }); + expect(res.ok()).toBe(true); + const body = await res.json(); + expect(body.downloadUrl).toContain(".avif"); + }); + + test("convert PNG to TIFF", async ({ request }) => { + const res = await request.post("/api/v1/tools/convert", { + headers: { Authorization: `Bearer ${token}` }, + multipart: { + file: { name: "test.png", mimeType: "image/png", buffer: PNG_200x150 }, + settings: JSON.stringify({ format: "tiff" }), + }, + }); + expect(res.ok()).toBe(true); + const body = await res.json(); + expect(body.downloadUrl).toContain(".tiff"); + }); + + test("convert PNG to GIF", async ({ request }) => { + const res = await request.post("/api/v1/tools/convert", { + headers: { Authorization: `Bearer ${token}` }, + multipart: { + file: { name: "test.png", mimeType: "image/png", buffer: PNG_200x150 }, + settings: JSON.stringify({ format: "gif" }), + }, + }); + expect(res.ok()).toBe(true); + const body = await res.json(); + expect(body.downloadUrl).toContain(".gif"); + }); + + test("convert JPEG to PNG", async ({ request }) => { + const res = await request.post("/api/v1/tools/convert", { + headers: { Authorization: `Bearer ${token}` }, + multipart: { + file: { name: "test.jpg", mimeType: "image/jpeg", buffer: JPG_100x100 }, + settings: JSON.stringify({ format: "png" }), + }, + }); + expect(res.ok()).toBe(true); + const body = await res.json(); + expect(body.downloadUrl).toContain(".png"); + }); + + test("convert HEIC to PNG", async ({ request }) => { + const res = await request.post("/api/v1/tools/convert", { + headers: { Authorization: `Bearer ${token}` }, + multipart: { + file: { name: "test.heic", mimeType: "image/heic", buffer: HEIC_200x150 }, + settings: JSON.stringify({ format: "png" }), + }, + }); + expect(res.ok()).toBe(true); + const body = await res.json(); + expect(body.downloadUrl).toContain(".png"); + }); + + test("convert PNG to HEIC", async ({ request }) => { + const res = await request.post("/api/v1/tools/convert", { + headers: { Authorization: `Bearer ${token}` }, + multipart: { + file: { name: "test.png", mimeType: "image/png", buffer: PNG_200x150 }, + settings: JSON.stringify({ format: "heic" }), + }, + }); + expect(res.ok()).toBe(true); + const body = await res.json(); + expect(body.downloadUrl).toContain(".heic"); + }); + + test("convert with quality parameter", async ({ request }) => { + const res = await request.post("/api/v1/tools/convert", { + headers: { Authorization: `Bearer ${token}` }, + multipart: { + file: { name: "test.png", mimeType: "image/png", buffer: PNG_200x150 }, + settings: JSON.stringify({ format: "jpg", quality: 10 }), + }, + }); + expect(res.ok()).toBe(true); + const body = await res.json(); + expect(body.processedSize).toBeGreaterThan(0); + }); + + test("convert rejects invalid format", async ({ request }) => { + const res = await request.post("/api/v1/tools/convert", { + headers: { Authorization: `Bearer ${token}` }, + multipart: { + file: { name: "test.png", mimeType: "image/png", buffer: PNG_200x150 }, + settings: JSON.stringify({ format: "bmp" }), + }, + }); + expect(res.ok()).toBe(false); + const body = await res.json(); + expect(body.error).toBeDefined(); + }); +}); + +// ─── Compress ──────────────────────────────────────────────────────── + +test.describe("Compress", () => { + test("compress with default settings", async ({ request }) => { + const res = await request.post("/api/v1/tools/compress", { + headers: { Authorization: `Bearer ${token}` }, + multipart: { + file: { name: "test.jpg", mimeType: "image/jpeg", buffer: JPG_100x100 }, + settings: JSON.stringify({}), + }, + }); + expect(res.ok()).toBe(true); + const body = await res.json(); + expect(body.downloadUrl).toBeTruthy(); + expect(body.processedSize).toBeGreaterThan(0); + }); + + test("compress with low quality produces smaller file", async ({ request }) => { + const res = await request.post("/api/v1/tools/compress", { + headers: { Authorization: `Bearer ${token}` }, + multipart: { + file: { name: "sample.jpg", mimeType: "image/jpeg", buffer: formatFixture("sample.jpg") }, + settings: JSON.stringify({ quality: 10 }), + }, + }); + expect(res.ok()).toBe(true); + const body = await res.json(); + expect(body.downloadUrl).toBeTruthy(); + // Low quality should produce a smaller file than the original + expect(body.processedSize).toBeLessThan(body.originalSize); + }); + + test("compress PNG image", async ({ request }) => { + const res = await request.post("/api/v1/tools/compress", { + headers: { Authorization: `Bearer ${token}` }, + multipart: { + file: { name: "test.png", mimeType: "image/png", buffer: PNG_200x150 }, + settings: JSON.stringify({ quality: 50 }), + }, + }); + expect(res.ok()).toBe(true); + const body = await res.json(); + expect(body.downloadUrl).toBeTruthy(); + }); + + test("compress WebP image", async ({ request }) => { + const webp = readFileSync(join(FIXTURES, "test-50x50.webp")); + const res = await request.post("/api/v1/tools/compress", { + headers: { Authorization: `Bearer ${token}` }, + multipart: { + file: { name: "test.webp", mimeType: "image/webp", buffer: webp }, + settings: JSON.stringify({ quality: 50 }), + }, + }); + expect(res.ok()).toBe(true); + const body = await res.json(); + expect(body.downloadUrl).toBeTruthy(); + }); +}); diff --git a/tests/e2e-docker/pipeline-tools.spec.ts b/tests/e2e-docker/pipeline-tools.spec.ts new file mode 100644 index 00000000..42c8fcf4 --- /dev/null +++ b/tests/e2e-docker/pipeline-tools.spec.ts @@ -0,0 +1,491 @@ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { expect, test } from "@playwright/test"; + +// ─── Pipeline Tools ───────────────────────────────────────────────── +// Tests for the pipeline/automation system: +// POST /api/v1/pipeline/execute — run a multi-step pipeline +// POST /api/v1/pipeline/save — save a pipeline definition +// GET /api/v1/pipeline/list — list saved pipelines +// DELETE /api/v1/pipeline/:id — delete a saved pipeline +// +// Pipelines chain tool steps: the output of step N feeds into step N+1. +// AI tools in a pipeline respect the same FEATURE_NOT_INSTALLED guard. + +const FIXTURES = join(process.cwd(), "tests", "fixtures"); +const FORMATS = join(FIXTURES, "formats"); + +let token: string; + +test.beforeAll(async ({ request }) => { + const res = await request.post("/api/auth/login", { + data: { username: "admin", password: "admin" }, + }); + const body = await res.json(); + token = body.token; +}); + +function fixture(name: string): Buffer { + return readFileSync(join(FIXTURES, name)); +} + +const PNG_200x150 = fixture("test-200x150.png"); +const JPG_100x100 = fixture("test-100x100.jpg"); +const JPG_SAMPLE = readFileSync(join(FORMATS, "sample.jpg")); + +// ─── Pipeline Execution ───────────────────────────────────────────── + +test.describe("Pipeline execution", () => { + test("execute single-step pipeline (resize)", async ({ request }) => { + const res = await request.post("/api/v1/pipeline/execute", { + headers: { Authorization: `Bearer ${token}` }, + multipart: { + file: { name: "test.png", mimeType: "image/png", buffer: PNG_200x150 }, + pipeline: JSON.stringify({ + steps: [{ toolId: "resize", settings: { width: 100, fit: "contain" } }], + }), + }, + }); + expect(res.ok()).toBe(true); + const body = await res.json(); + expect(body.downloadUrl).toBeTruthy(); + expect(body.processedSize).toBeGreaterThan(0); + expect(body.steps).toBeTruthy(); + }); + + test("execute two-step pipeline (resize then compress)", async ({ request }) => { + const res = await request.post("/api/v1/pipeline/execute", { + headers: { Authorization: `Bearer ${token}` }, + multipart: { + file: { name: "sample.jpg", mimeType: "image/jpeg", buffer: JPG_SAMPLE }, + pipeline: JSON.stringify({ + steps: [ + { toolId: "resize", settings: { width: 400, fit: "contain" } }, + { toolId: "compress", settings: { quality: 60 } }, + ], + }), + }, + }); + expect(res.ok()).toBe(true); + const body = await res.json(); + expect(body.downloadUrl).toBeTruthy(); + expect(body.processedSize).toBeGreaterThan(0); + // The pipeline output should be smaller than the original sample + expect(body.processedSize).toBeLessThan(body.originalSize); + }); + + test("execute three-step pipeline (resize, rotate, border)", async ({ request }) => { + const res = await request.post("/api/v1/pipeline/execute", { + headers: { Authorization: `Bearer ${token}` }, + multipart: { + file: { name: "test.png", mimeType: "image/png", buffer: PNG_200x150 }, + pipeline: JSON.stringify({ + steps: [ + { toolId: "resize", settings: { width: 100, fit: "contain" } }, + { toolId: "rotate", settings: { angle: 90 } }, + { toolId: "border", settings: { size: 10, color: "#ff0000" } }, + ], + }), + }, + }); + expect(res.ok()).toBe(true); + const body = await res.json(); + expect(body.downloadUrl).toBeTruthy(); + }); + + test("execute pipeline with format conversion", async ({ request }) => { + const res = await request.post("/api/v1/pipeline/execute", { + headers: { Authorization: `Bearer ${token}` }, + multipart: { + file: { name: "test.png", mimeType: "image/png", buffer: PNG_200x150 }, + pipeline: JSON.stringify({ + steps: [ + { toolId: "resize", settings: { width: 150, fit: "contain" } }, + { toolId: "convert", settings: { format: "webp" } }, + ], + }), + }, + }); + expect(res.ok()).toBe(true); + const body = await res.json(); + expect(body.downloadUrl).toBeTruthy(); + // Output should be WebP + expect(body.downloadUrl).toContain(".webp"); + }); + + test("execute pipeline with color adjustments and sharpening", async ({ request }) => { + const res = await request.post("/api/v1/pipeline/execute", { + headers: { Authorization: `Bearer ${token}` }, + multipart: { + file: { name: "test.jpg", mimeType: "image/jpeg", buffer: JPG_100x100 }, + pipeline: JSON.stringify({ + steps: [ + { toolId: "adjust-colors", settings: { brightness: 10, contrast: 15 } }, + { toolId: "sharpening", settings: { sigma: 1.5 } }, + ], + }), + }, + }); + expect(res.ok()).toBe(true); + const body = await res.json(); + expect(body.downloadUrl).toBeTruthy(); + }); + + test("execute pipeline with watermark and optimize", async ({ request }) => { + const res = await request.post("/api/v1/pipeline/execute", { + headers: { Authorization: `Bearer ${token}` }, + multipart: { + file: { name: "sample.jpg", mimeType: "image/jpeg", buffer: JPG_SAMPLE }, + pipeline: JSON.stringify({ + steps: [ + { + toolId: "watermark-text", + settings: { + text: "SAMPLE", + fontSize: 48, + color: "#ff0000", + opacity: 30, + position: "center", + }, + }, + { toolId: "optimize-for-web", settings: { maxWidth: 1200, quality: 75 } }, + ], + }), + }, + }); + expect(res.ok()).toBe(true); + const body = await res.json(); + expect(body.downloadUrl).toBeTruthy(); + }); +}); + +// ─── Pipeline with AI Tools ───────────────────────────────────────── + +test.describe("Pipeline with AI tools", () => { + test("pipeline with AI tool returns 501 when feature not installed", async ({ request }) => { + const res = await request.post("/api/v1/pipeline/execute", { + headers: { Authorization: `Bearer ${token}` }, + multipart: { + file: { name: "test.png", mimeType: "image/png", buffer: PNG_200x150 }, + pipeline: JSON.stringify({ + steps: [ + { toolId: "resize", settings: { width: 100 } }, + { toolId: "remove-background", settings: {} }, + ], + }), + }, + }); + + // Check whether the feature is installed + const featuresRes = await request.get("/api/v1/features", { + headers: { Authorization: `Bearer ${token}` }, + }); + const features = await featuresRes.json(); + const bgBundle = features.bundles?.find((b: { id: string }) => b.id === "background-removal"); + const isInstalled = bgBundle?.status === "installed"; + + if (!isInstalled) { + // Pipeline should fail at the AI step with 501 + expect(res.status()).toBe(501); + const body = await res.json(); + expect(body.code).toBe("FEATURE_NOT_INSTALLED"); + } else { + expect(res.ok()).toBe(true); + const body = await res.json(); + expect(body.downloadUrl).toBeTruthy(); + } + }); + + test("mixed pipeline: non-AI steps before AI step", async ({ request }) => { + const res = await request.post("/api/v1/pipeline/execute", { + headers: { Authorization: `Bearer ${token}` }, + multipart: { + file: { name: "test.jpg", mimeType: "image/jpeg", buffer: JPG_100x100 }, + pipeline: JSON.stringify({ + steps: [ + { toolId: "resize", settings: { width: 80, fit: "contain" } }, + { toolId: "upscale", settings: { scale: 2, model: "auto" } }, + ], + }), + }, + }); + + const featuresRes = await request.get("/api/v1/features", { + headers: { Authorization: `Bearer ${token}` }, + }); + const features = await featuresRes.json(); + const upscaleBundle = features.bundles?.find((b: { id: string }) => b.id === "upscale-enhance"); + const isInstalled = upscaleBundle?.status === "installed"; + + if (!isInstalled) { + expect(res.status()).toBe(501); + } else { + expect(res.ok()).toBe(true); + const body = await res.json(); + expect(body.downloadUrl).toBeTruthy(); + } + }); +}); + +// ─── Pipeline Validation ──────────────────────────────────────────── + +test.describe("Pipeline validation", () => { + test("reject pipeline with no steps", async ({ request }) => { + const res = await request.post("/api/v1/pipeline/execute", { + headers: { Authorization: `Bearer ${token}` }, + multipart: { + file: { name: "test.png", mimeType: "image/png", buffer: PNG_200x150 }, + pipeline: JSON.stringify({ steps: [] }), + }, + }); + expect(res.ok()).toBe(false); + const body = await res.json(); + expect(body.error).toBeDefined(); + }); + + test("reject pipeline with unknown tool", async ({ request }) => { + const res = await request.post("/api/v1/pipeline/execute", { + headers: { Authorization: `Bearer ${token}` }, + multipart: { + file: { name: "test.png", mimeType: "image/png", buffer: PNG_200x150 }, + pipeline: JSON.stringify({ + steps: [{ toolId: "nonexistent-tool", settings: {} }], + }), + }, + }); + expect(res.ok()).toBe(false); + const body = await res.json(); + expect(body.error).toBeDefined(); + }); + + test("reject pipeline with no file", async ({ request }) => { + const res = await request.post("/api/v1/pipeline/execute", { + headers: { Authorization: `Bearer ${token}` }, + multipart: { + pipeline: JSON.stringify({ + steps: [{ toolId: "resize", settings: { width: 100 } }], + }), + }, + }); + expect(res.ok()).toBe(false); + }); + + test("reject pipeline with invalid JSON", async ({ request }) => { + const res = await request.post("/api/v1/pipeline/execute", { + headers: { Authorization: `Bearer ${token}` }, + multipart: { + file: { name: "test.png", mimeType: "image/png", buffer: PNG_200x150 }, + pipeline: "not valid json {{{", + }, + }); + expect(res.ok()).toBe(false); + }); + + test("reject pipeline with invalid tool settings", async ({ request }) => { + const res = await request.post("/api/v1/pipeline/execute", { + headers: { Authorization: `Bearer ${token}` }, + multipart: { + file: { name: "test.png", mimeType: "image/png", buffer: PNG_200x150 }, + pipeline: JSON.stringify({ + steps: [{ toolId: "resize", settings: { width: -100 } }], + }), + }, + }); + expect(res.ok()).toBe(false); + const body = await res.json(); + expect(body.error).toBeDefined(); + }); +}); + +// ─── Pipeline Save / List / Delete ────────────────────────────────── + +test.describe("Pipeline CRUD", () => { + test("save a pipeline definition", async ({ request }) => { + const res = await request.post("/api/v1/pipeline/save", { + headers: { Authorization: `Bearer ${token}` }, + data: { + name: "E2E Test Pipeline", + description: "Resize then compress for web optimization", + steps: [ + { toolId: "resize", settings: { width: 800, fit: "contain" } }, + { toolId: "compress", settings: { quality: 75 } }, + ], + }, + }); + expect(res.ok()).toBe(true); + const body = await res.json(); + expect(body.id).toBeTruthy(); + }); + + test("list saved pipelines", async ({ request }) => { + const res = await request.get("/api/v1/pipeline/list", { + headers: { Authorization: `Bearer ${token}` }, + }); + expect(res.ok()).toBe(true); + const body = await res.json(); + expect(body.pipelines || body).toBeInstanceOf(Array); + }); + + test("delete a saved pipeline", async ({ request }) => { + // First save one to ensure we have something to delete + const saveRes = await request.post("/api/v1/pipeline/save", { + headers: { Authorization: `Bearer ${token}` }, + data: { + name: "Temp Pipeline for Deletion", + steps: [{ toolId: "resize", settings: { width: 100 } }], + }, + }); + expect(saveRes.ok()).toBe(true); + const { id } = await saveRes.json(); + + // Delete it + const deleteRes = await request.delete(`/api/v1/pipeline/${id}`, { + headers: { Authorization: `Bearer ${token}` }, + }); + expect(deleteRes.ok()).toBe(true); + + // Verify it's gone from the list + const listRes = await request.get("/api/v1/pipeline/list", { + headers: { Authorization: `Bearer ${token}` }, + }); + expect(listRes.ok()).toBe(true); + const listBody = await listRes.json(); + const pipelines = listBody.pipelines ?? listBody; + const found = pipelines.find((p: { id: string }) => p.id === id); + expect(found).toBeUndefined(); + }); + + test("save pipeline rejects empty name", async ({ request }) => { + const res = await request.post("/api/v1/pipeline/save", { + headers: { Authorization: `Bearer ${token}` }, + data: { + name: "", + steps: [{ toolId: "resize", settings: { width: 100 } }], + }, + }); + expect(res.ok()).toBe(false); + const body = await res.json(); + expect(body.error).toBeDefined(); + }); + + test("save pipeline rejects empty steps", async ({ request }) => { + const res = await request.post("/api/v1/pipeline/save", { + headers: { Authorization: `Bearer ${token}` }, + data: { + name: "Empty Pipeline", + steps: [], + }, + }); + expect(res.ok()).toBe(false); + const body = await res.json(); + expect(body.error).toBeDefined(); + }); + + test("delete nonexistent pipeline returns 404", async ({ request }) => { + const res = await request.delete("/api/v1/pipeline/00000000-0000-0000-0000-000000000000", { + headers: { Authorization: `Bearer ${token}` }, + }); + expect(res.status()).toBe(404); + }); +}); + +// ─── Pipeline Execution Edge Cases ────────────────────────────────── + +test.describe("Pipeline edge cases", () => { + test("HEIC input is decoded before first step", async ({ request }) => { + const heic = fixture("test-200x150.heic"); + const res = await request.post("/api/v1/pipeline/execute", { + headers: { Authorization: `Bearer ${token}` }, + multipart: { + file: { name: "test.heic", mimeType: "image/heic", buffer: heic }, + pipeline: JSON.stringify({ + steps: [{ toolId: "resize", settings: { width: 100, fit: "contain" } }], + }), + }, + }); + expect(res.ok()).toBe(true); + const body = await res.json(); + expect(body.downloadUrl).toBeTruthy(); + }); + + test("strip-metadata then convert pipeline", async ({ request }) => { + const jpgExif = fixture("test-with-exif.jpg"); + const res = await request.post("/api/v1/pipeline/execute", { + headers: { Authorization: `Bearer ${token}` }, + multipart: { + file: { name: "photo.jpg", mimeType: "image/jpeg", buffer: jpgExif }, + pipeline: JSON.stringify({ + steps: [ + { toolId: "strip-metadata", settings: {} }, + { toolId: "convert", settings: { format: "webp" } }, + ], + }), + }, + }); + expect(res.ok()).toBe(true); + const body = await res.json(); + expect(body.downloadUrl).toBeTruthy(); + expect(body.downloadUrl).toContain(".webp"); + }); + + test("text-overlay then border pipeline", async ({ request }) => { + const res = await request.post("/api/v1/pipeline/execute", { + headers: { Authorization: `Bearer ${token}` }, + multipart: { + file: { name: "test.png", mimeType: "image/png", buffer: PNG_200x150 }, + pipeline: JSON.stringify({ + steps: [ + { + toolId: "text-overlay", + settings: { + text: "Pipeline Test", + fontSize: 24, + color: "#FFFFFF", + position: "center", + }, + }, + { toolId: "border", settings: { size: 5, color: "#000000" } }, + ], + }), + }, + }); + expect(res.ok()).toBe(true); + const body = await res.json(); + expect(body.downloadUrl).toBeTruthy(); + }); + + test("full processing pipeline: resize, enhance, sharpen, watermark, compress", async ({ + request, + }) => { + const res = await request.post("/api/v1/pipeline/execute", { + headers: { Authorization: `Bearer ${token}` }, + multipart: { + file: { name: "sample.jpg", mimeType: "image/jpeg", buffer: JPG_SAMPLE }, + pipeline: JSON.stringify({ + steps: [ + { toolId: "resize", settings: { width: 600, fit: "contain" } }, + { toolId: "image-enhancement", settings: { preset: "auto" } }, + { toolId: "sharpening", settings: { sigma: 1.0 } }, + { + toolId: "watermark-text", + settings: { + text: "ashim", + fontSize: 20, + color: "#808080", + opacity: 20, + position: "bottom-right", + }, + }, + { toolId: "compress", settings: { quality: 80 } }, + ], + }), + }, + }); + expect(res.ok()).toBe(true); + const body = await res.json(); + expect(body.downloadUrl).toBeTruthy(); + expect(body.processedSize).toBeGreaterThan(0); + // Full pipeline should produce a smaller file than original + expect(body.processedSize).toBeLessThan(body.originalSize); + }); +}); diff --git a/tests/e2e-docker/utility-tools.spec.ts b/tests/e2e-docker/utility-tools.spec.ts new file mode 100644 index 00000000..e7fcefb4 --- /dev/null +++ b/tests/e2e-docker/utility-tools.spec.ts @@ -0,0 +1,646 @@ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { expect, test } from "@playwright/test"; + +// ─── Utility Tools ────────────────────────────────────────────────── +// Tests for: info, compare, find-duplicates, color-palette, +// qr-generate, barcode-read, bulk-rename +// These tools extract metadata, compare images, and generate assets. + +const FIXTURES = join(process.cwd(), "tests", "fixtures"); +const FORMATS = join(FIXTURES, "formats"); +const CONTENT = join(FIXTURES, "content"); + +let token: string; + +test.beforeAll(async ({ request }) => { + const res = await request.post("/api/auth/login", { + data: { username: "admin", password: "admin" }, + }); + const body = await res.json(); + token = body.token; +}); + +function fixture(name: string): Buffer { + return readFileSync(join(FIXTURES, name)); +} + +function formatFixture(name: string): Buffer { + return readFileSync(join(FORMATS, name)); +} + +function contentFixture(name: string): Buffer { + return readFileSync(join(CONTENT, name)); +} + +/** + * Build a raw multipart/form-data body. Playwright's `multipart` option + * does not support arrays for the same field name, so multi-file uploads + * must be assembled manually. + */ +function buildMultipart( + files: Array<{ name: string; filename: string; contentType: string; buffer: Buffer }>, + fields: Array<{ name: string; value: string }>, +): { body: Buffer; contentType: string } { + const boundary = `----PlaywrightBoundary${Date.now()}`; + const parts: Buffer[] = []; + for (const file of files) { + parts.push( + Buffer.from( + `--${boundary}\r\nContent-Disposition: form-data; name="${file.name}"; filename="${file.filename}"\r\nContent-Type: ${file.contentType}\r\n\r\n`, + ), + ); + parts.push(file.buffer); + parts.push(Buffer.from("\r\n")); + } + for (const field of fields) { + parts.push( + Buffer.from( + `--${boundary}\r\nContent-Disposition: form-data; name="${field.name}"\r\n\r\n${field.value}\r\n`, + ), + ); + } + parts.push(Buffer.from(`--${boundary}--\r\n`)); + return { + body: Buffer.concat(parts), + contentType: `multipart/form-data; boundary=${boundary}`, + }; +} + +const PNG_200x150 = fixture("test-200x150.png"); +const JPG_100x100 = fixture("test-100x100.jpg"); +const JPG_WITH_EXIF = fixture("test-with-exif.jpg"); +const HEIC_200x150 = fixture("test-200x150.heic"); +const WEBP_50x50 = fixture("test-50x50.webp"); + +// ─── Info ──────────────────────────────────────────────────────────── + +test.describe("Info", () => { + test("returns metadata for PNG image", async ({ request }) => { + const res = await request.post("/api/v1/tools/info", { + headers: { Authorization: `Bearer ${token}` }, + multipart: { + file: { name: "test.png", mimeType: "image/png", buffer: PNG_200x150 }, + }, + }); + expect(res.ok()).toBe(true); + const body = await res.json(); + + expect(body.filename).toBe("test.png"); + expect(body.width).toBe(200); + expect(body.height).toBe(150); + expect(body.format).toBe("png"); + expect(body.channels).toBeGreaterThan(0); + expect(typeof body.hasAlpha).toBe("boolean"); + expect(body.fileSize).toBeGreaterThan(0); + expect(body.colorSpace).toBeTruthy(); + }); + + test("returns metadata for JPEG image", async ({ request }) => { + const res = await request.post("/api/v1/tools/info", { + headers: { Authorization: `Bearer ${token}` }, + multipart: { + file: { name: "test.jpg", mimeType: "image/jpeg", buffer: JPG_100x100 }, + }, + }); + expect(res.ok()).toBe(true); + const body = await res.json(); + + expect(body.width).toBe(100); + expect(body.height).toBe(100); + expect(body.format).toBe("jpeg"); + }); + + test("returns EXIF info for JPEG with metadata", async ({ request }) => { + const res = await request.post("/api/v1/tools/info", { + headers: { Authorization: `Bearer ${token}` }, + multipart: { + file: { name: "test-with-exif.jpg", mimeType: "image/jpeg", buffer: JPG_WITH_EXIF }, + }, + }); + expect(res.ok()).toBe(true); + const body = await res.json(); + + expect(body.hasExif).toBe(true); + expect(body.fileSize).toBeGreaterThan(0); + }); + + test("returns metadata for HEIC image", async ({ request }) => { + const res = await request.post("/api/v1/tools/info", { + headers: { Authorization: `Bearer ${token}` }, + multipart: { + file: { name: "test.heic", mimeType: "image/heic", buffer: HEIC_200x150 }, + }, + }); + expect(res.ok()).toBe(true); + const body = await res.json(); + + expect(body.width).toBeGreaterThan(0); + expect(body.height).toBeGreaterThan(0); + expect(body.fileSize).toBeGreaterThan(0); + }); + + test("returns histogram data", async ({ request }) => { + const res = await request.post("/api/v1/tools/info", { + headers: { Authorization: `Bearer ${token}` }, + multipart: { + file: { name: "test.png", mimeType: "image/png", buffer: PNG_200x150 }, + }, + }); + expect(res.ok()).toBe(true); + const body = await res.json(); + + expect(body.histogram).toBeInstanceOf(Array); + expect(body.histogram.length).toBeGreaterThan(0); + + const firstChannel = body.histogram[0]; + expect(firstChannel.channel).toBeTruthy(); + expect(typeof firstChannel.min).toBe("number"); + expect(typeof firstChannel.max).toBe("number"); + expect(typeof firstChannel.mean).toBe("number"); + expect(typeof firstChannel.stdev).toBe("number"); + }); + + test("returns metadata for WebP image", async ({ request }) => { + const res = await request.post("/api/v1/tools/info", { + headers: { Authorization: `Bearer ${token}` }, + multipart: { + file: { name: "test.webp", mimeType: "image/webp", buffer: WEBP_50x50 }, + }, + }); + expect(res.ok()).toBe(true); + const body = await res.json(); + + expect(body.width).toBe(50); + expect(body.height).toBe(50); + expect(body.format).toBe("webp"); + }); + + test("returns metadata for BMP from formats fixture", async ({ request }) => { + const bmp = formatFixture("sample.bmp"); + const res = await request.post("/api/v1/tools/info", { + headers: { Authorization: `Bearer ${token}` }, + multipart: { + file: { name: "sample.bmp", mimeType: "image/bmp", buffer: bmp }, + }, + }); + // BMP may not be supported if Sharp can't decode it natively + if (res.ok()) { + const body = await res.json(); + expect(body.width).toBeGreaterThan(0); + expect(body.height).toBeGreaterThan(0); + } else { + expect([400, 422]).toContain(res.status()); + } + }); + + test("rejects request with no file", async ({ request }) => { + const res = await request.post("/api/v1/tools/info", { + headers: { Authorization: `Bearer ${token}` }, + multipart: {}, + }); + expect(res.ok()).toBe(false); + }); +}); + +// ─── Compare ──────────────────────────────────────────────────────── + +test.describe("Compare", () => { + test("compare two identical images returns high similarity", async ({ request }) => { + const { body, contentType } = buildMultipart( + [ + { name: "file", filename: "a.png", contentType: "image/png", buffer: PNG_200x150 }, + { name: "file", filename: "b.png", contentType: "image/png", buffer: PNG_200x150 }, + ], + [], + ); + const res = await request.post("/api/v1/tools/compare", { + headers: { Authorization: `Bearer ${token}`, "Content-Type": contentType }, + data: body, + }); + expect(res.ok()).toBe(true); + const json = await res.json(); + // API returns similarity as a percentage (0-100) + expect(json.similarity).toBeGreaterThan(99); + expect(json.downloadUrl).toBeTruthy(); + }); + + test("compare two different images returns lower similarity", async ({ request }) => { + const { body, contentType } = buildMultipart( + [ + { name: "file", filename: "a.png", contentType: "image/png", buffer: PNG_200x150 }, + { name: "file", filename: "b.jpg", contentType: "image/jpeg", buffer: JPG_100x100 }, + ], + [], + ); + const res = await request.post("/api/v1/tools/compare", { + headers: { Authorization: `Bearer ${token}`, "Content-Type": contentType }, + data: body, + }); + expect(res.ok()).toBe(true); + const json = await res.json(); + // API returns similarity as a percentage (0-100) + expect(typeof json.similarity).toBe("number"); + expect(json.similarity).toBeGreaterThanOrEqual(0); + expect(json.similarity).toBeLessThanOrEqual(100); + }); + + test("compare rejects single file", async ({ request }) => { + const { body, contentType } = buildMultipart( + [{ name: "file", filename: "a.png", contentType: "image/png", buffer: PNG_200x150 }], + [], + ); + const res = await request.post("/api/v1/tools/compare", { + headers: { Authorization: `Bearer ${token}`, "Content-Type": contentType }, + data: body, + }); + expect(res.ok()).toBe(false); + const json = await res.json(); + expect(json.error).toContain("Two"); + }); +}); + +// ─── Find Duplicates ──────────────────────────────────────────────── + +test.describe("Find Duplicates", () => { + test("detect duplicates among identical images", async ({ request }) => { + const { body, contentType } = buildMultipart( + [ + { name: "file", filename: "a.png", contentType: "image/png", buffer: PNG_200x150 }, + { name: "file", filename: "b.png", contentType: "image/png", buffer: PNG_200x150 }, + { name: "file", filename: "c.jpg", contentType: "image/jpeg", buffer: JPG_100x100 }, + ], + [], + ); + const res = await request.post("/api/v1/tools/find-duplicates", { + headers: { Authorization: `Bearer ${token}`, "Content-Type": contentType }, + data: body, + }); + expect(res.ok()).toBe(true); + const json = await res.json(); + + // Should detect the two identical PNGs as duplicates + expect(json.duplicateGroups).toBeTruthy(); + expect(json.duplicateGroups.length).toBeGreaterThan(0); + }); + + test("no duplicates among unique images", async ({ request }) => { + const portrait = readFileSync(join(CONTENT, "portrait-bw.jpeg")); + const motorcycle = readFileSync(join(CONTENT, "motorcycle.heif")); + const { body, contentType } = buildMultipart( + [ + { name: "file", filename: "portrait.jpeg", contentType: "image/jpeg", buffer: portrait }, + { + name: "file", + filename: "motorcycle.heif", + contentType: "image/heif", + buffer: motorcycle, + }, + ], + [], + ); + const res = await request.post("/api/v1/tools/find-duplicates", { + headers: { Authorization: `Bearer ${token}`, "Content-Type": contentType }, + data: body, + }); + expect(res.ok()).toBe(true); + const json = await res.json(); + + // Visually distinct images — zero duplicate groups + expect(json.duplicateGroups).toBeInstanceOf(Array); + expect(json.duplicateGroups.length).toBe(0); + }); + + test("find-duplicates response includes file info", async ({ request }) => { + const { body, contentType } = buildMultipart( + [ + { name: "file", filename: "a.png", contentType: "image/png", buffer: PNG_200x150 }, + { name: "file", filename: "b.png", contentType: "image/png", buffer: PNG_200x150 }, + ], + [], + ); + const res = await request.post("/api/v1/tools/find-duplicates", { + headers: { Authorization: `Bearer ${token}`, "Content-Type": contentType }, + data: body, + }); + expect(res.ok()).toBe(true); + const json = await res.json(); + + // Response should contain duplicateGroups and totalImages + expect(json.duplicateGroups).toBeTruthy(); + expect(json.totalImages).toBeGreaterThan(0); + }); +}); + +// ─── Color Palette ────────────────────────────────────────────────── + +test.describe("Color Palette", () => { + test("extract colors from PNG", async ({ request }) => { + const res = await request.post("/api/v1/tools/color-palette", { + headers: { Authorization: `Bearer ${token}` }, + multipart: { + file: { name: "test.png", mimeType: "image/png", buffer: PNG_200x150 }, + }, + }); + expect(res.ok()).toBe(true); + const body = await res.json(); + + expect(body.filename).toBe("test.png"); + expect(body.colors).toBeInstanceOf(Array); + expect(body.colors.length).toBeGreaterThan(0); + expect(body.count).toBe(body.colors.length); + + // Each color should be a hex string + for (const color of body.colors) { + expect(color).toMatch(/^#[0-9a-fA-F]{6}$/); + } + }); + + test("extract colors from JPEG", async ({ request }) => { + const res = await request.post("/api/v1/tools/color-palette", { + headers: { Authorization: `Bearer ${token}` }, + multipart: { + file: { name: "test.jpg", mimeType: "image/jpeg", buffer: JPG_100x100 }, + }, + }); + expect(res.ok()).toBe(true); + const body = await res.json(); + + expect(body.colors).toBeInstanceOf(Array); + expect(body.colors.length).toBeGreaterThan(0); + }); + + test("extract colors from sample JPEG has diverse palette", async ({ request }) => { + const sample = formatFixture("sample.jpg"); + const res = await request.post("/api/v1/tools/color-palette", { + headers: { Authorization: `Bearer ${token}` }, + multipart: { + file: { name: "sample.jpg", mimeType: "image/jpeg", buffer: sample }, + }, + }); + expect(res.ok()).toBe(true); + const body = await res.json(); + + // A real photograph should have multiple distinct colors + expect(body.colors.length).toBeGreaterThanOrEqual(3); + }); + + test("extract colors from HEIC image", async ({ request }) => { + const res = await request.post("/api/v1/tools/color-palette", { + headers: { Authorization: `Bearer ${token}` }, + multipart: { + file: { name: "test.heic", mimeType: "image/heic", buffer: HEIC_200x150 }, + }, + }); + expect(res.ok()).toBe(true); + const body = await res.json(); + expect(body.colors).toBeInstanceOf(Array); + }); +}); + +// ─── QR Generate ──────────────────────────────────────────────────── + +test.describe("QR Generate", () => { + test("generate QR code from URL", async ({ request }) => { + const res = await request.post("/api/v1/tools/qr-generate", { + headers: { Authorization: `Bearer ${token}` }, + data: { + text: "https://ashim.app", + size: 512, + }, + }); + expect(res.ok()).toBe(true); + const body = await res.json(); + expect(body.downloadUrl).toBeTruthy(); + expect(body.processedSize).toBeGreaterThan(0); + }); + + test("generate QR code with custom colors", async ({ request }) => { + const res = await request.post("/api/v1/tools/qr-generate", { + headers: { Authorization: `Bearer ${token}` }, + data: { + text: "Hello World", + size: 400, + foreground: "#0000FF", + background: "#FFFF00", + }, + }); + expect(res.ok()).toBe(true); + const body = await res.json(); + expect(body.downloadUrl).toBeTruthy(); + }); + + test("generate QR code with high error correction", async ({ request }) => { + const res = await request.post("/api/v1/tools/qr-generate", { + headers: { Authorization: `Bearer ${token}` }, + data: { + text: "https://github.com/ashim-hq/ashim", + size: 300, + errorCorrection: "H", + }, + }); + expect(res.ok()).toBe(true); + const body = await res.json(); + expect(body.downloadUrl).toBeTruthy(); + }); + + test("generate QR code with minimum size", async ({ request }) => { + const res = await request.post("/api/v1/tools/qr-generate", { + headers: { Authorization: `Bearer ${token}` }, + data: { + text: "test", + size: 100, + }, + }); + expect(res.ok()).toBe(true); + const body = await res.json(); + expect(body.downloadUrl).toBeTruthy(); + }); + + test("reject empty text", async ({ request }) => { + const res = await request.post("/api/v1/tools/qr-generate", { + headers: { Authorization: `Bearer ${token}` }, + data: { + text: "", + size: 400, + }, + }); + expect(res.ok()).toBe(false); + const body = await res.json(); + expect(body.error).toBeDefined(); + }); + + test("reject size below minimum", async ({ request }) => { + const res = await request.post("/api/v1/tools/qr-generate", { + headers: { Authorization: `Bearer ${token}` }, + data: { + text: "test", + size: 10, + }, + }); + expect(res.ok()).toBe(false); + }); +}); + +// ─── Barcode Read ─────────────────────────────────────────────────── + +test.describe("Barcode Read", () => { + test("read QR code from generated image (round-trip)", async ({ request }) => { + // Generate a QR code first, then read it back + const genRes = await request.post("/api/v1/tools/qr-generate", { + headers: { Authorization: `Bearer ${token}` }, + data: { text: "https://example.com/test", size: 400 }, + }); + expect(genRes.ok()).toBe(true); + const genBody = await genRes.json(); + + // Download the generated QR image + const dlRes = await request.get(genBody.downloadUrl, { + headers: { Authorization: `Bearer ${token}` }, + }); + expect(dlRes.ok()).toBe(true); + const qrBuffer = Buffer.from(await dlRes.body()); + + // Now read it with barcode-read + const res = await request.post("/api/v1/tools/barcode-read", { + headers: { Authorization: `Bearer ${token}` }, + multipart: { + file: { name: "qr.png", mimeType: "image/png", buffer: qrBuffer }, + settings: JSON.stringify({}), + }, + }); + expect(res.ok()).toBe(true); + const body = await res.json(); + + expect(body.barcodes).toBeInstanceOf(Array); + expect(body.barcodes.length).toBeGreaterThan(0); + + const barcode = body.barcodes[0]; + expect(barcode.text).toBe("https://example.com/test"); + }); + + test("read barcode from barcode fixture", async ({ request }) => { + const barcodeImage = contentFixture("barcode.avif"); + const res = await request.post("/api/v1/tools/barcode-read", { + headers: { Authorization: `Bearer ${token}` }, + multipart: { + file: { name: "barcode.avif", mimeType: "image/avif", buffer: barcodeImage }, + settings: JSON.stringify({}), + }, + }); + expect(res.ok()).toBe(true); + const body = await res.json(); + + expect(body.barcodes).toBeInstanceOf(Array); + // Should detect at least one barcode + if (body.barcodes.length > 0) { + expect(body.barcodes[0].text).toBeTruthy(); + expect(body.annotatedUrl).toBeTruthy(); + } + }); + + test("return empty array for image with no barcodes", async ({ request }) => { + const res = await request.post("/api/v1/tools/barcode-read", { + headers: { Authorization: `Bearer ${token}` }, + multipart: { + file: { name: "test.png", mimeType: "image/png", buffer: PNG_200x150 }, + settings: JSON.stringify({}), + }, + }); + expect(res.ok()).toBe(true); + const body = await res.json(); + + expect(body.barcodes).toBeInstanceOf(Array); + expect(body.barcodes.length).toBe(0); + expect(body.annotatedUrl).toBeNull(); + }); + + test("annotated image URL returned when barcodes found", async ({ request }) => { + const qrImage = contentFixture("qr-code.avif"); + const res = await request.post("/api/v1/tools/barcode-read", { + headers: { Authorization: `Bearer ${token}` }, + multipart: { + file: { name: "qr-code.avif", mimeType: "image/avif", buffer: qrImage }, + settings: JSON.stringify({}), + }, + }); + expect(res.ok()).toBe(true); + const body = await res.json(); + + if (body.barcodes.length > 0) { + expect(body.annotatedUrl).toBeTruthy(); + expect(body.previewUrl).toBeTruthy(); + + // Verify the annotated image can be downloaded + const downloadRes = await request.get(body.annotatedUrl, { + headers: { Authorization: `Bearer ${token}` }, + }); + expect(downloadRes.ok()).toBe(true); + } + }); +}); + +// ─── Bulk Rename ──────────────────────────────────────────────────── + +test.describe("Bulk Rename", () => { + test("rename files with default pattern", async ({ request }) => { + const { body: reqBody, contentType } = buildMultipart( + [ + { name: "file", filename: "photo1.png", contentType: "image/png", buffer: PNG_200x150 }, + { name: "file", filename: "photo2.jpg", contentType: "image/jpeg", buffer: JPG_100x100 }, + ], + [{ name: "settings", value: JSON.stringify({ pattern: "image-{{index}}" }) }], + ); + const res = await request.post("/api/v1/tools/bulk-rename", { + headers: { Authorization: `Bearer ${token}`, "Content-Type": contentType }, + data: reqBody, + }); + expect(res.ok()).toBe(true); + // Bulk rename returns a ZIP with renamed files + const resContentType = res.headers()["content-type"] ?? ""; + // Should be a ZIP or JSON with download URL + if (resContentType.includes("application/json")) { + const json = await res.json(); + expect(json.downloadUrl || json.files).toBeTruthy(); + } else { + // Binary ZIP response + const buffer = Buffer.from(await res.body()); + expect(buffer.length).toBeGreaterThan(0); + } + }); + + test("rename files with custom pattern and start index", async ({ request }) => { + const { body: reqBody, contentType } = buildMultipart( + [ + { name: "file", filename: "a.png", contentType: "image/png", buffer: PNG_200x150 }, + { name: "file", filename: "b.jpg", contentType: "image/jpeg", buffer: JPG_100x100 }, + { name: "file", filename: "c.webp", contentType: "image/webp", buffer: WEBP_50x50 }, + ], + [ + { + name: "settings", + value: JSON.stringify({ pattern: "vacation-{{index}}", startIndex: 100 }), + }, + ], + ); + const res = await request.post("/api/v1/tools/bulk-rename", { + headers: { Authorization: `Bearer ${token}`, "Content-Type": contentType }, + data: reqBody, + }); + expect(res.ok()).toBe(true); + }); + + test("reject empty file list", async ({ request }) => { + const { body: reqBody, contentType } = buildMultipart( + [], + [{ name: "settings", value: JSON.stringify({ pattern: "test-{{index}}" }) }], + ); + const res = await request.post("/api/v1/tools/bulk-rename", { + headers: { Authorization: `Bearer ${token}`, "Content-Type": contentType }, + data: reqBody, + }); + expect(res.ok()).toBe(false); + const json = await res.json(); + expect(json.error).toBeDefined(); + }); +}); diff --git a/tests/fixtures/content/animated-simpsons.gif b/tests/fixtures/content/animated-simpsons.gif new file mode 100644 index 00000000..9a180fe8 Binary files /dev/null and b/tests/fixtures/content/animated-simpsons.gif differ diff --git a/tests/fixtures/content/barcode.avif b/tests/fixtures/content/barcode.avif new file mode 100644 index 00000000..d934e106 Binary files /dev/null and b/tests/fixtures/content/barcode.avif differ diff --git a/tests/fixtures/content/cross-format-chat.webp b/tests/fixtures/content/cross-format-chat.webp new file mode 100644 index 00000000..d4f68425 Binary files /dev/null and b/tests/fixtures/content/cross-format-chat.webp differ diff --git a/tests/fixtures/content/motorcycle.heif b/tests/fixtures/content/motorcycle.heif new file mode 100644 index 00000000..7a68f35d Binary files /dev/null and b/tests/fixtures/content/motorcycle.heif differ diff --git a/tests/fixtures/content/multi-face.webp b/tests/fixtures/content/multi-face.webp new file mode 100644 index 00000000..82b8d018 Binary files /dev/null and b/tests/fixtures/content/multi-face.webp differ diff --git a/tests/fixtures/content/ocr-chat.jpeg b/tests/fixtures/content/ocr-chat.jpeg new file mode 100644 index 00000000..455704a3 Binary files /dev/null and b/tests/fixtures/content/ocr-chat.jpeg differ diff --git a/tests/fixtures/content/ocr-japanese.png b/tests/fixtures/content/ocr-japanese.png new file mode 100644 index 00000000..ef042c98 Binary files /dev/null and b/tests/fixtures/content/ocr-japanese.png differ diff --git a/tests/fixtures/content/portrait-bw.jpeg b/tests/fixtures/content/portrait-bw.jpeg new file mode 100644 index 00000000..78047581 Binary files /dev/null and b/tests/fixtures/content/portrait-bw.jpeg differ diff --git a/tests/fixtures/content/portrait-color.jpg b/tests/fixtures/content/portrait-color.jpg new file mode 100644 index 00000000..c4e97838 Binary files /dev/null and b/tests/fixtures/content/portrait-color.jpg differ diff --git a/tests/fixtures/content/portrait-headshot.heic b/tests/fixtures/content/portrait-headshot.heic new file mode 100644 index 00000000..ee4cbfd6 Binary files /dev/null and b/tests/fixtures/content/portrait-headshot.heic differ diff --git a/tests/fixtures/content/portrait-isolated.png b/tests/fixtures/content/portrait-isolated.png new file mode 100644 index 00000000..84cd4286 Binary files /dev/null and b/tests/fixtures/content/portrait-isolated.png differ diff --git a/tests/fixtures/content/qr-code.avif b/tests/fixtures/content/qr-code.avif new file mode 100644 index 00000000..30bb3682 Binary files /dev/null and b/tests/fixtures/content/qr-code.avif differ diff --git a/tests/fixtures/content/qr-code.svg b/tests/fixtures/content/qr-code.svg new file mode 100644 index 00000000..3c3dac7d --- /dev/null +++ b/tests/fixtures/content/qr-code.svg @@ -0,0 +1,3 @@ + + + diff --git a/tests/fixtures/content/red-eye.jpg b/tests/fixtures/content/red-eye.jpg new file mode 100644 index 00000000..c2b54415 Binary files /dev/null and b/tests/fixtures/content/red-eye.jpg differ diff --git a/tests/fixtures/content/stress-large.jpg b/tests/fixtures/content/stress-large.jpg new file mode 100644 index 00000000..b67ea005 Binary files /dev/null and b/tests/fixtures/content/stress-large.jpg differ diff --git a/tests/fixtures/content/svg-logo.svg b/tests/fixtures/content/svg-logo.svg new file mode 100644 index 00000000..c51a29f3 --- /dev/null +++ b/tests/fixtures/content/svg-logo.svg @@ -0,0 +1,38 @@ + + SVG Drop Shadow Filter Effects - ConvertICO + Sample SVG demonstrating drop shadow and glow filter effects. Free download at ConvertICO.com + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + CONVERTICO + Filter Effects Sample + convertico.com/samples/svg + diff --git a/tests/fixtures/content/watermark.jpg b/tests/fixtures/content/watermark.jpg new file mode 100644 index 00000000..dec3dfe5 Binary files /dev/null and b/tests/fixtures/content/watermark.jpg differ diff --git a/tests/fixtures/formats/multipage.tiff b/tests/fixtures/formats/multipage.tiff new file mode 100644 index 00000000..86df9d5f Binary files /dev/null and b/tests/fixtures/formats/multipage.tiff differ diff --git a/tests/fixtures/formats/sample.avif b/tests/fixtures/formats/sample.avif new file mode 100644 index 00000000..06595c41 Binary files /dev/null and b/tests/fixtures/formats/sample.avif differ diff --git a/tests/fixtures/formats/sample.bmp b/tests/fixtures/formats/sample.bmp new file mode 100644 index 00000000..ea88c92d Binary files /dev/null and b/tests/fixtures/formats/sample.bmp differ diff --git a/tests/fixtures/formats/sample.dng b/tests/fixtures/formats/sample.dng new file mode 100644 index 00000000..e732e009 Binary files /dev/null and b/tests/fixtures/formats/sample.dng differ diff --git a/tests/fixtures/formats/sample.exr b/tests/fixtures/formats/sample.exr new file mode 100644 index 00000000..b7e51349 Binary files /dev/null and b/tests/fixtures/formats/sample.exr differ diff --git a/tests/fixtures/formats/sample.gif b/tests/fixtures/formats/sample.gif new file mode 100644 index 00000000..5686b1ba Binary files /dev/null and b/tests/fixtures/formats/sample.gif differ diff --git a/tests/fixtures/formats/sample.hdr b/tests/fixtures/formats/sample.hdr new file mode 100644 index 00000000..ca25e624 --- /dev/null +++ b/tests/fixtures/formats/sample.hdr @@ -0,0 +1,726 @@ +#?RGBE +GAMMA=0.454545 +PRIMARIES=0.64 0.33 0.3 0.6 0.15 0.06 0.3127 0.329 +FORMAT=32-bit_rle_rgbe + +-Y 426 +X 640 +IÅҮʄ-ƾҴ¦⸼̬ʠ®´~⺄₂䖎|z쒬Ԏ절ت|ʠ$~~Ѧʖ¼ʶľʺҸs~ԴȾ +ڄ E}zz{wqnppoligddcd\Xa[ecZ1x~tz|(~||xz~|zt~nxp|tzxvzt||vx~vxttrrpxvpzxnr\bhzbl\\|hllrtrltjbprltp^r^dltvtljljdjjhhhjllnjffhhfbn^trnbhnr`lplhlzbbn\p^pxhjdr^\tlvdppfdlhz^hgph~zzhwblplphfrv^tdflztlpphjlnnljln\~pfjhltnhhlnnlpnnlllnnlnnnrxxtl|h|^zrnhxxft~lzprtntxljxzrnvprrt$rXtp`~fpznv`~td|vt~|lxxxzxx||xz|x||z~~xvkhpzzZe +2TSUTWE}{zz}~upyr|{sĥԦ¶ʄ1ΰмڰĦȞ澈愄ܔŒڔಈΨ;ĐҠžΘ̶Ƽ¾ށ 2wtvuw~~~~,̅(ΰȸȰƺĢОԢƢ貐|ҌvW~z~xԶ}z|섘)|~ڬ|!~#|xĆB¾¾оʸ޸چwƬȺо؈0,}|zvrrponljhfe^efag^uc^]['~bz~|zzz~thfvvt|lpzz~pnxxxzzvvttntlth~vjdvlfltpzt|hpllnrpjnxj\xdl|f`p^rdhtztjflljjhjjllljhhlnnnthlrfft|fWrzhjbXt\brhho`VbZ~nb|xjjdt|ndvtfdfTrlpn~pvjpl\ppXrnhtxplppjhjlnlnprt`tjnjnlhfn)pnpplntrlZ\Xvvr\xfxnvxptvnvvnr~|pxz |bptnhnbtttr^dn|zv|xz"|zz~~zz~~z||||zpr|rfiöRAfm^x2UVWZY]Y,~||{xzv܋{vutԄH潲ʲ̴ʬ²⌾&Ơ̖ʚȤ䰒䞊̬ļĄ,ĄR€ⴈشȸȔĢ#иҺֆ9Դ2yyx{z}v▀ +~~~~~@܄6ºȲмܶ¸ؖ侦꒪Ϊ|̬~螤𢎆t[|z~~~xܔ~𔔒S~pn@֔zƺ(¶ְ༮sܬƆԂ҆ +ކ2򄇂@~zutvponmjihg`cf^bc``_][ZXzd6~jn.z~zzzzt|Njzhh||v~|xx|zxxzzxvtxz|Zjnzdpvvnnt~XrtrrtvpprXȰx~phxfpXltrpntxrhllljjlllnnlhffhlhrlltjf~znfxrXjVrjjpjzp{Zlhdj~drrfrpjZ`v\p^bZz^nbnx)^֠vr`rpxrrnfnpllrrjjlnnnlnpvlpb|hvhjnprnSpprprpnptrlt|fpd|tXb|tpxtlp|pxvnr~vxvvtvvxztp~JxvtppvdNvrlxvz|@~~|z|||~~pnfzlgrz||(|Zu~x +/fidXUYZ^ZB~}z}wz{vxwutsrxƄ4ȼľܦ¬ȴԮ֔⊤Ƥή~䠦ꆚ`ނ윘Sʺ؊ΨʮКƦĬD¬쪢ȺԬȼƾЄ̄(ʁ +/{y}{x斀~~~~~~~x΅μƸֺ¾ĺภĤ#|~搶G腴|ڀp~ʺ|ȅȔ䶒΂Ў얈ܠ䞘Tzxpvțnʐ¸ڼ̸ҺȃȺڇ ܄ޅ2x}zwuwrqonkjhgf`c]\ea`\_][Z[Xhvtx#zz~~||v|HRvxzrb~G|~~~vxxzzxtt`tx^`t|zd~zxrrjhvbrvtrvzxrnnoZnphnrv`rdfxxhl|fnnnlpjllhdflpZttfnvld~hfzzfj^vljXrdspvhlftn`tzd~jv|^rv`nndrfsp|tfrrfhlljntrlrttpljjlvb|ztnppnlntznSprrtprrptzzthlpmV~xn~zltpxztv|pnxzrvvtrpprvx~hzrRmJvxjPzdrT~lx~|~n~~~v~pvjrlooV||im 1YTXY^[x~}z}vu}ywuwvtstrܼzºʸ´дʸ޶¢#΂ފG쒒pŠЉ؜žֆT􅶊ƈ̜¼ܶIJʬ̲Γ␔3}x|{y柀~~~~~~~~~.F丞̸¾ƲԸkฯȜ䈪®䞖~싘vxPʪআ|ʚ~~ނԌ|x~xSvܪznž euڄʴبʒtخʾاޅ#$.|||yvuuspomkhfh^fhcd\[]_^\[\YYF~rlrvk~~x|~~rXuTvdhZ||vr|vvxxxvvt|tflvjdtnrzzd\lxvrpprtvpjprxrbdjzdxXTjzxrttrpnPfhllnrxjlpjdvx\nzxtl`hxz^njr`tbm^p^hzldnfΘbXvjvZrln`zjsjvpZvtrnprpltttrnjjl^tvTlTprvvtnjlnpSrttrttrtzztxfvtrRȘ|dnvvt|~trz~trxxpr~zxvttxz||l~TxHbpvtrjxpp|zlv~ z~e|nvtb^jtxZn|zh#$X[XTVY[_[]ZY[.~|x||wuwxxvuvutڄFҦƲ̰޾k~Ȝނ좚€PҲƒ֦䐜„SĦĐƘȮĨҮ ĸΪ򴷏 ${{wz~~|ywtv~~~~~~~~ WֺȾȶȾƮȺƶ¦آ(ЈȬĠ̦N~Ɩ}z{઎|z~ଌ༆q䃾|ު~̶¶޴̺ĺެyܼ@{}!{{}zvtwtqonlifi^ehc`[_]][\[YWUVepp|zv~(z~xz~|r\ƺzrr|ttpxrr|bjtxpj|~lvv~b|nzprtxvphlZr`rtfxlhpbrzrnzrpnNppnnptjjpfjvpfvx~tdnjrpZrjvjrjr\kVvt^rzfjp^ijXvjhhxlnvlpvt\oœjzdpqrrrpnprrft~qXdjrvvrljrppprrttvvtrrvtnn|xnpzrp`xth~tr|~vx~pt~rnxzrzzxxxzz|rtxdXtlthnxv~rrvx~~||nvnjzj{ux_@[a_WWY[ZVSW[[`]W]a\[]{|x}yvzxwvvvtsqĨyĺ°ڴ²֠(ԊȨľЪOΞԁځƶ谐qʈʔتĸ̪ʸθʲΪܪΪԢƆB򀆄~}yw|~wz~zwy~~~~~~~H̄Є'¸ָܴƺƸ ‚Ĝ༬Ħ|zʦcxȀԶԢ҈ް옒𬊌*ج#unȒƦ̼ؒ´¼¾Ƽuހwڴ֋|s?{yyz~w}~|yvuwtpoonkhkbca``]f_ZX[ZVTWXc|t|n~~~~tx\vt|hnlz||zxxz|~Ztltrfx~vpvnvrfvtjtxvttzzvrb\vptppz`xf\dfrvvxrtttrpnntrppnlhftprjjc|vxj||bbzxZjd|rn~¤\\\xrrrbf|d|~dtlr|ljryhxrnprrnlptprtrpprttdẍfhlrvvtrttr*ttvxvrrvtn^xztv^`x~z|pr||tvrrvrz|tv#xxxvrrjeNlhrvj~vlzrxtz|xlv[xf]r{cT?]YVWYX\YWSXZY]]V_c^[^w~|}{||yzuuwwsqss·wȾƾ֮в¶´ޘ‚Ĝܸ¤Үc̪Ј侴ڐ욚VήƦРآظ¼¶´ľƶ܂򙢊?}}{y~~w|z}~~~ ~}~~~~ 0ҪԄ/־̲ҸԼºĸƦhվȠⲪ¦⊊~ښxƸ쾊ҐԪܺ誢Mz҄Їz@ƸƼ¾„xԼy·stsutuuzz~0|wvwvsoopolijhicedY\b\Y^^WVZWp/zhz|~z~~pp˜rzrxlv~|zz|nfhhdzlvxl|ttln\~znrvvnltvrnzh|lvt|xjrprvT~tbp|xtrnnljlrxzxtdhzt`vpxjzxtrpll~pxrjjtlpntnxpd̤fjldlrrh|pfbondjnrrnjpxtvvtpnnphlttt~\Ć~rllprrvttrrrttvxvtvM|zvjpv|vxtV`tv~vt~~vt|ztxtvvvxzzzxx~rx~z|bwZ|~v~vx~t@~~zxn|xm^n}hVUTVUVVZU]YXWY`Y[ZVZ\[^[TT_c]Z]0}ux~wv{{tsws؄޾҄/̾ҺȬ̲ζ¼ĤhվȠޮ掎ܜĴƒژ聀ج༬M┨Ę撐ºĸĬ?ļƶظخڅքxꕤ~~|uu|z}~~~~~~~4޽ʼƀºªʮ؜ҢҖȢްĶ܆ꎄ蜤m~𜒌ܴ|ʘ~θޮĸ~𔚪v´&Įʮ¾̄ȅoֶs̾}ttsuutsuwy|}}3}zxwxusrqnkknjgdba_^\[baYX_^XWZ[YYZYz~|zpz~z~z|~n\|tv|pzhjZ~pxrtnlh^txfz~n|r|lnrjvrptp`jtprrfv~pzmdxjhx|vb|X||z\hhvnhtpjzdzvlprrxvpnv~rrnz~xnhzb|h\pxzt^|Xzp^frdtrtfx`~jzxhl\|`ttrzndvpprppnrpntxpbtfxvh~zvrrvxvrlt~vttrttppvzxVlrtj~zrztp|zxz|ztnxvzxt|r~zrVjvlvxv|v~v~|~&zt~j~odiXp`UUTVVUTVVY^ZZ[WY[WV\WV[Z_]TW_`aca4~|{yy~vu|{usuvttutքظжȼ;Ҿƪ֚ԤӘȠڪڄ쐆Ꞧ켺Ҡм踦Œ֌ҚȤȮ®ľʸƲج؄ބp䍒|}xx~~~~~J꽨҆ЀԼĬֲ컊´®İ~Ќبy蚐҄xƂ~ƈȔܶx~ʛx–Đ⒰uƚĦ¼¾„Ƅȃʅ(utsttDsutvuvxy~~y}}z}J~{yyyxvtrnjjlkigecb`_\``[Z]]YX[[ZYZYr~|~~~x~|~~x\ojʪhtx~~fXxv~~lfzxrrrptjd}|zp~d{^ptlnztrtnz^~nv||^bph|d|ptppxvptrnt~plvxzxtv~~|xt`rhfTvv||~`Ȣ^z~xvdhvlZzvnjlnb|dnn~dbvnbrplzjprprvtptzxprlxfh|httrtx|tnpx|zvxzxrrvxtlPvxh|lxnrn|zzz||zzxxp~|xrz~x||r^lvfj|~|tz~zclv~fxh_nz|~(ZYTUUTVUWVWXY^`[Y$_b]ZU[WZ[\V\WX^\`_WX_^]^\J}|z~}xwzzvtvvututܵЄ΀ľļи԰̼ЀҎФ𰪐꜔؊Ί膦ƬȊҞĆĢДतОƤ¼¼֪҄¾мܨ̺ԅބ($|{|~~~~~ 0˪¼ƼҞƦ˜¬ƸʾƐƒș}rஜژ誔઎ꂌ~|uԪvƊx¼¤!ª²yΞĄ¾¼̅؅,stvtstuvwwyxx{|wxy*z{~𤿴0|zyyxvtspkkmnligedba^^]\\[[[Y[[YYZY#tn|||zl|~~|~xxxnԚd~vpxrbdhz~~jzlpzpnvr~zpznXvdxrxnlzzjdrjl|gr|`|runhhvpvxptxvzxpxnp~zrvpvxrnr|tfz~t^hlvrjZs~hd~zrvjlXz^rrrb|jztnp`rdjz|np|zrturrtxtprz|zvZnntlrtxxxvvxztrtxzzz|zttxxv^ʴNbzzx~vtxzzxxxz|x~x~tlz|~|zz~x|zPrvx|~p~~!|zdlzzr,WUWUTUVWXVXXXX\^YWXY*Z[\^]bt_tth\[[WVWV[WYa^aaYZ`^\]['||{zyxzzuwwutut'ضš¸ΚĤļĎȔ؝𴢒ژk气򊔘Ɛġ䄶ƪ޴ДIJȈ̪ҰȲƸƶ̾ƾ̾־θ…޸,*Ĺ}|~}~~~~~~~~ΐƾ̰Ҳ̲ʸȶւĚʺȢ̌Ȟ|쀚ƪMԞȪ솴zxxܶඖֆ+Фج̸Ԧº„ȅ҄9utstszxwsxwsuuutuvvvvxzxyy{{z(~{~{yxxwussqnnpmlhfdcb``\[^^ZZ]Z[[YZ[[Ys~hxz~||~~|~~xh``t|xz|nb~|z|z`|pzxrt|~phxf|XИr|rr\|rzxtrppt\x~pdn~pxVoZtbd|v|pz~rv~rrxxrxrMlrvtnpv|tj~hrzsvn|xtVlhtxlbxbzTtzhj~vvptTvhrdzfplv`zpvrrrttrvtrrtxztxjt`xvz~~xtrtvxpv~vxxvv|~|n~dʴnjvrt~z|~|xvxz|vx~~x|zxt|b|^vzx~+hzz|~~zz9ZYWXW^YXTYXTVVVUVWWWWWZXYVX[Z(\^^`d[[W][\YW[X[b^`_X[a__a_(~~zy|{wy|vwwuuvvt༇ʼ¾º¼Ȭΰʰȶƴ؄˜ȸʤ蚦Ȟ򆠮̰ئز΀Ĭ´斮Pºƺ¾¾Φ丸ľބ9샂,}~~~~~~~ ~ȾĴ̨̬Ȫ䮑ȺĢ̸zࠒ~F~袐ކ’Ħ}ؼ3ĺ࠴ਢ־¼ʆ̅҄'uuuz|wvxwv z|yx~{zy*~~~~|zywxussronqmkigedb`a\\_^YZ_Z\[ZY[\[opxz~~~~|xz~~|~t|x|~vhz~|vh`zvvz|vvxfxfj`nf~lxtx~tlt|֒^tx|rdzXhrz~t|~nrxr|zpp||rrvFxxvvv~fpvdzdlr^ptplbpzZ|tntlbqlzpljll~rpjz|htrldth~jnrprprvxxvtrtt~jjzhtvz~~zvtvp~|txvzzxx~|nrd^xrx|xjz~~||~t~xjd|zz|vttx`ir~l.lvt~v'VVVY[VUWVUW~~~v|v~vJ^~Rizj?zm]\xYYZZ[[XZ]_^[Vvsj^VX\`[[\^``]Z_`a``_`aba``abbbdddcbbdfdgfeeggeadebdghdjffgbdihb``cddcdadb`^adfb`dm|nqf~}|zyxvwxxxvutокܸƲҪ̬BҖؐʜȒ¢Π$ƤȬܤ@ȬζºڊڒԼĄ>́ +~~؀ 4亃΅>ȂȢм֮΁ЦʾĮ¨Ĩ贬ԨPĢƊ–Ģ7pvªƒܮTغҁήظиԶҘr{}{~}z|z}}}}肍~~ }xzzxwwwvvwguqktpfilhefea`c_3^]]^[Z`V_]~epn>fp~~|hvztzv~n~r|~v|`vzvtpx~|~|xlrʘv~xtzzvxvzx|vxvhmzzxp||~||~zl~~v~~x|P~zvvvpl|xvvz||xx|`zX^ptp~pl|tv|~|z|~nfbl|rzz||x7~~~z~~|HPpɜjTh|kp]_[^]ZY\Z]_]]_]Z^ie\Y]aaa^[Y[`e_[XZ`c`\]^aabih^dedbadiohhfffhhidggdehhechmle`ena`a]gnabgeab`abe\qbl}ppg 3~}|}zx~t}{خ}ą>}־ʶƺ̨~ʢƺ¬ƪ𸮞΢PάƜԘΞ̬ȸȺ<¾ĸƸրİڢ躼„Tܼꤏo~~~~׀5?ڼޮܬڲʸȚƪآ´¬̊䰬ʜnƠ֤ƨ|Ԕذ¦~Ʀ¾ľм}ȶƴv}z޼u|zr|{~~{}|~~}5}yr||vrrttswlmlgsvbgffgebbe_aa_[YY[VSWVXXZZY?ƀ|~zrz~|~xnvvdx|\x|lb~zz|tbhzl~z~~xx|vxxpz~zn{m\rlnnp~~ztzpxvn|l~n~prx|~~||~~~|v|T|}Zzvhzxvt~v|~~~|||~v|rrptvrhnp~t~|tjTw^|rrxd^|cb[\\C\[^^[Z]\_`^_a_[^\Y\djf]```aa`^\gjkf_\`fbbecbhiacdefgfdcjkih(giiggjjhmnonidfjgacdmndjmidbabbcfsfqzprj5{yy{urutvvxwv?Բ⟮мвƾԾب֦ޞ֮¾—¦֠ĶԒ¼첮ԏƘئTΆĮȼ¸Ȩ¾ƾή⒖ԲҲЄЈ҂p~~~ЁҺެضܬȾд԰žشȔҊƚҖҺΦ⮨–VƘ~¦Į谖䔺ª:¼~{̸ĸޏ޼ҾξĄQȺ¶кyܲм־xڗ{y|{z{|{{z~|{}~T~yy}xxywuvyunwkorgisnefghfbacbdda\Z\_[j^f[\ZY[Zc~lgzz`|x~|xxzl\rhdjvv|vxt~~nx~|ntlĬdz~~||xz|zxlxj~ljrf~|rdv~|~v|jVd~|~z~rlh~vz|zzvvxzzxj|l~uPxxtz~|~~z~~~~~tfp\~v:vpdzz~~~ThlXzV|zQ`^{a[\\[p\]^][Z^\[]]\\]^_^^`b`\__]]afgee`ZX\aeg[]_``behddfc^cgagffilkfblmkkjiggfhhgfhhgoljjifeejadirneqpnhfceghooftumsi|z|z}yzxwxw{ƮڲئҰ֦ؾľʮЬĠ’ڶʖԌ꘼–~Βζʢ䰪ĘX¬ƘބȬʴ̸ Įʢ²ĸ)¾ĸȸ֒ҬҖ虔̼κ΄QȀ惄T~~~~ ຋ƪėº¦ܸžȶتЦʴpܰƠ䮔ТԨƊ²𾘲䎚zވƞ|ԻԈԸκиȾԲ|Ծw΄ξ|||{{t}}}{z|}}}|}{|x|v}uzyvvyvnpplszhawkiggeca`ccca^^`be]c_YYY~mk~x~vxzz||~xurV~lzr~x~~~~|vjtʌ|xz~xzjhnvxpjnl~|~pp~hvtx~~zt|xZvvzzxvtx|tzphrb}jvv|zz~|~~t~~~vdptndfrxl||Pr^jtVb\hxc[hyY^^][p\^__]\_][\\]]]__]^`a_\\beb\[aggghihd^Ya``cfgc_cdge_bfbihffikkjkmlmljhgghhhghhhjhfgigfgibbgrsiqnqnjegmqtigxrlth~~~|؁}wvvԮ޺Ķļ澔޺ʸڬ̢ưpت氖ҤҪƊ²Ğ°蚐̸ʶļ¾Ҳ̚¨҃⎡p~~ޏ΁жĝκ蚁ȀĨƾڲ̨кԦp̐ĸƢƠ|֔⠔\tĶʤsʸԸκ̂ζ҅1w~~xzz{{|}{{}L{~}}|}|{{~~~||j|}zrortrtqfm~ufkplfceec`da^]_`_^lbZ]ZYZYclhh|~~~z|pdjVp|zp||~xxzprxxv~~|~x||xvrjvhtppz΂nzr~zřdzxn~vn|zz|~~zz~ztpxvbxT}l||~z|~z||~~r|||~tnʎrxl|rXqJl~`zllpg[}l^l\`Z\\[[\][[ZL[^__^]\[[_^\[Z\\]`^^_aa`_d`^_bc`]Y\`bb`__adfebabedejjefhefffedeiliklmmlihjjjk!lihikjhilgcbcqzmlltvmegpysej}vrxk~}~Ёx{xvwv̾{޺Ⱥưľ¸ȼİ~¦ļܰʦ̸ТrΒ¨¸ƠĤ̪ƪ쮢Ķʬ–̺¾ƾ¼ԪՀ̪ì؈ +7 " +~~[θ􀈊־؅8̔ƾ̦¼ʪ䔇ȤάȬ䂊¬ȍľ첮ʴƲ?zެԬrv̚؜԰~ԼξҺƲzyxԅ|昡րxz{{zz{||~q|}||}}~~[v~twuvxxvtrqrnrnhvycolfcehgchc__`a^[U]_]YZYwz7{štk~~x|~z~qx~xnzz~xzx||rl^~trbu^rl|~z~~|b|npvS~~~vjpltz|||zz|~dltfvRunx~||zx|~~~~~n~nlrzt~x|Habz|~hntTbpv|_^Ziv\]vbZ\][ZZ[\\[q\__^^]]^_^``][\]^baabbaacf_\aggb^hgdcdlxgd`_aeghdchkggieehkjhfgiiklmnmkkkjjllkkmklmmjgimgefes}njlwzpfhrznblzvwxm[~{ܟs{}{vwvʲ̅8ƾƑʾľ¼Ơ貾ļȨʦ̪ƨ⁈ċ欨̶𜺾SƲ𢞾¨¨ʠȠ䲬⺮ԺΆļԢĔԠ̶̰Ҿ쏜j~~8ج^檂мҬ¾ƚ̪ľ֤Ȕ´겪Ȓڪ̰ƲȚƦČʺʆƐĐꪤܬⶰРԪº̼zʼ|zμ5zzv{~؀{{|{yyyzq}|{|}~8yoy|vu{}wqoloyrblwlmnkfeggffdbbba_]eZYZYY^ndhy|hx||tndrPzzp~|z~dplf|zxzlwfx|~zxz|jv\Rt||x~V|h~|xvljh|b|bxp~~zz|~|~xxhlvnĂ|~p~tjb~~vv~~VgȢx_a_5Z`ZZa[_[^r]\\[YYYZ\P]\\\]^`ab[ac_\]^_bacdb`afbacedbbfacddc`^]yqigjkgcgcfifghdgilmmkjikmmnnnmmkiilljjlmjghkigjluxjllwzqjltzjcmvv{yp8}֢xwwvv^ڞ|ްжܸʶޏΨºޖƤҠĔ´殦Ɛ֦ƪ®ȚƦČʺʆĮȪ̘Ȕи¨Ԡ¼¼ĺƾÞƖQȀքރ􃅅D(ʵҲּܼʲ€̺¼ҘҾĴОʰuޮȜš~~μʢʴȊ̮̾|zzx{{懐yyzzz|nwoxtturqsnrmnvletkqqkddee_`aa`_^^\gza][YY[knp~|z~~~~p^h~z|t|~|tr|zz~lr~~|vxx~|||~~|xpp~zzxv|~tzd~fpt~~|||xtvzrnvz~~x|xzz~~~zztzzzxvXjzppzptdx~Ұ`ZZZXXgra[[ZZZ\_a]p``_^^]]][cfb]]_``acda]`gecbdcaaedcbdgiig^eklgegjmffifhjggfgilnlkmnnoonnmnkknokknmlkmmkkmkfmqvpcljtuolrx{liqtx|u~~~|ҙzxvvxʾθμ̬܁Ҳ̲ȺĬȶҘκΜƬꪲԺޮ΢ưʢªʾʾҮ0ȺľҮवܾ܅Kg +ԫj؈ΎڀƮԾ̨¾ĶʸkİĞĶʔؐĘȪĬ9梠ȦԒ4|j̞ݼľ¾Ȫ|yּܼ̒{F{~~~P~}z{}|yvi}fzupxsqqpjlolpomjhfjabb_aa]bY``^^qh|zkk||r|~~xnr~d`|~|z~nVlx~rpb~z9v|~Vvzzv||~ftqzvp|t|4~|~|t||vrd~vx|tth|vƆ~j|rxn|Ld`fx]D[^fb^\^__^`b^\^_]]__[[dgc_^]^`aaa__\`bedcbagfePgaaikfdgggecdjlmhhfijkhe_tddumhfhruokmnknligrqbhjmoomkkkfmkjrldirgqtouwwoeqyzypj{ڦо{zz‡޴ȼ亾̼~̶Ƣ恼ºIJkİĞĶʔؐ²–ƨª8„褦ʨ֔5ºIJ򃲬@ƶ²ܮȺƸƼԾܐ֜ڄp􃋇P +8бؒƋζ̸ʢޤԸƪ֜¶İ丰¸Ҝ̮ƢĨʖʲ ĮgƤƪʨƬ²ưĴȰЦڞĬ4̚{ޢȦܮȾʶƼجܸȼ޺yzy#{{{|}색!98~{{~}{xej}smqnpqnopjlmmmlifdeacd_`a]ZQe^ka]n̚rªp|nsx|tzlzt~zrxdjv| ~hztxxj~tx|jjzlfvz|x~frpf|v|4~|x|pvrrl\xz|~lgntn~~vx~|j~|ZZY#[[[\]jnfag`_^_bc`_`Z_caZ]f^_`a bbaabegdcccdefggcchjgfhcgkkkjgafg:ijr`kzamqncanvplplkplklhvrrrokkmpkmkoxtmrymrsqyxtnft{{yr8~}t}ʬ}zހθڲʸȰƲƞܢβԺЖ²Һ丰¸Ҝ̮ƢĨʘ̴ʴ-ĢĨƤ¨Į²5Ĭ֬¨”ܠĚȰ´5ĦĬƨРưʸ¨;ضĺĴȼ̸̬ĺ؇a쁀 :̤޷ć̍мغުԾȜİܐHʪ¤IJĪȨƬ֜ĺҤиƈhƪ¢Ʋ֪ȦŠʠƶ3¼ΨҢ֛ƈ|Ƽļʬʴжڮβyw|{ yy|{]μ|zy{zyx|soxh~ttopropojlkkkmkhddaee__`\f]Thfvc`]apzʸj`rH|vxnxzxx|r||̀zlltv^b~~|gx~~nvhp~~~vvhv~r|zz|j^trxz|3~~z|t||nnptzxz~xzZ~|ltjz|~Y~W\XYY\__[_hh`^ a`^]_\_acee`[`abc]bbcfikjhgffgggeefghhgjkljijhfgijhfgjnnknn_o{jhkljifrqklkqsssqmmpsvpks{slqyorpsxokiw||zu~tư|z~ʾΰʶҶڦҸؐHƤмĪȨ¨ҘҤҺˆhĨ®ذʨČ̢ʺ5ʪʲܶܬƼİ䢐оAƬάƇȺԴ̶־举܅3&]п +⫽ȋȰ̾c֨ڤдƚºƪ⮲ʔ´ƠȜQ´Ħ薨Ĵ覲®ȔĘ4ƶԨʔڇ̴:¬ش~®кľ̺¦ڼܶҾ܄lҁ~w{{{|{{z{|}~{yz|~}{}|}Z~{ywxxxw{urspvwysrrmmonokggjkkieacfbb`[Q_aSs_Z`n\}czvjv~x~zllv~|dzpl~z~~|~tt~rrpQnpvjj|z|~|v€xnp~fdj|4~~~x|hpl~|~vlbsrp|:v|pgtla^X`[[[\XXZ[\]^[YZ\^][]\ajh_`h``^^]]^``]Z`nthV``_`abdeaceYfonmjgggheggefijhkljggkmmkklkijlndpch~nV\t|pwgmpnsnjmpstrrst{oluxmiruprnuypjm|}}zy~trҏȞ{w}μжƾʸ޾ʺªȺcТԞдƚ¦ުʔĶĞƚQ攦²İ̘ʞ6ĸ⶜š¨آ莨־9İҼİĄκʰ؅lZ +œⷽփڍʺⰾʇʾƨʈP¬ڞʪ̲Ɛ¸¼Шؾʬʢ 䢲Ħ0ƶ⼜ȼª3 ֤ԎĮȂ~ƬBžʼƾ¼Įʰ²ƺ܆0z|www|xyz{}}~}|{z{}}򃄅Ĺ }|{ztwujpkuprtnknookgfikkjhabeceaY^kRb`Y[`]YcrĐ~oĜxP||nzx~bz~xxx`xzx~|rzv frv~0|xtl|~zt|xlbvz~3~~|x~xxtb`lzvZjd@|zn~tkx|r|0Z{c^bXXXc]ZY5Z[]]^]\[Z[]_``\Zbke^_`_`aaa^`_[]dhf`___`ade`bdeeedeefSgjleggeehjidhjjkljhmlklmmkjnhoqijt}mikrrsolkkusomqtutstvxlnzxmo|tuxrxzukr~}~{}~}~r}uvƫ{xt{ȅĺ̾ڨ·ĸƆR֚ƦƬּ̤Ȫƞ /²ĖƮ5Ю䨖Ʀޘθ̤ʮ֐䅚Ժ@κҮ̸ʮˆθҶξ܄0򁢉ڄ􁌆%Sö ګʋԾҴʲʲ¾Ҡe²ƴ̖ެȶʪƨրȢޮƤȷЪ̢ļꖰpМԨڠʸ¸¸Ƽȸ궜֔؊؅4Ⱦ˜ĴȺڤڪθƾԮܼ؄ڂ>Γ|xuv~xxx{zzyyz +}}{}{~*J¾Ā~}}{zyzyszyrymwplqvqmmmjiijlljhibbecebZhWb[b`cc]Ysozpeu~f~|zzRnt~ttj^~x~rxtn|~~\ƺf|p~p|tpznvv~xpfnt~nf|~tnbq|~:|thv~prrx|8u]YVW`_ZZZ[ZZYYZ +]`_][]_X[^a*\]_`bda^\bea\]bfaa``abdecdbaacficeggffhjfghJjknonllnnmnlkknnliyamsgqtfurppmkkmtkqqnqnqvwvtrswzznr|ukpzswytztvnx}}~wx{~~xtʺ҆ľڼθ̮ɾ¼֤eؼ°ȒڨIJƦzœڪ ĶΨȞ撬rмҞ֪ޤμĪƼƼ¸ƪ®ľƶĬ¤挲?δҨƪƾ޸9ܛ9J7EֆޔԾ̼ЮаªΕаܴ0̶ЂIJʸȰҼ̮°ިƾĠ¾ºmĤآ̮ºΔ¸¾²ʪ܊ؠĸڶ3¢ⰆƐξ¾0ФĦ¶Ԥҍ਼ʼº}Ĵބڄ*yʄuzywwyvyzy{}||{!|z}|dſ}}|{zxwwyylwtrpzpouspomj%kjihhcdeabc_[ZXrXk\Xh`YEfx~uxxnz~0nRr~ht|~vppzrtr~|zd^~~~nkdXnrz|ztjz~t%tlXvb|ppv~x0vzt~ztxrjtrd*YdYYXVVXUaYZY[]\\[!\Z]__]\\^f_`d^Wfge`]_cb]_^afhd`]bddfd[egigikkhffhhgimlhimutpkjnqqnmllmoppilztmpkrktwru|xljlvvuskrxxwuttwz~pu|slpsruus|}qwt~A|zuuwsޢ{vE~δ̎ꌦθƶ攴ʴʮʒȾʪ֮/Ȳ~ԼƲȲ䘠¤آºԞȪʐފ¼Ĵ̬´ƺ޺̬'¬Ĥ涌КƪȮĴļ†0ʰഞвܒ̾*؋![ ⿻ΚؒތЦİ +ܐ̾<̰ԪIJȰ¶ĢƢļʬʻ̠ȶ춰ܰȺ´ȴƲĸ¸´άĪ ֠Ь@̬к¶ºȦ찪}Аܲy̤}@xvywvvwwyzzxwz}|||}~~d~~}{yyzz|suvsmyorsomljgfhjlfegf__cc_s]ha]_~sZYºvpp~yv<^xzttv|t`tzt~|x~vrdºRzljPtz|vzpxtzx~~|xz |plr~~tzZ||lt~xkb|wYd^AWUXVUUVVYZZXWZ]\\\]^_``^]]\\]^_]]\]`c_Y|tf]\ddab\]bebaeddcdde_cfhhgfebfjkihiligiqoihpinopopkinoonmpuxvmskquswutwsnqxxvsmtwp}ruxxwwxy}ot|vswvsstts{wԎvywzȲwv̌ԇʼκ沶Ⱦ֍ڞƇ<ȬЦ¾Ժ⪺ƨƺʞIJ責جĶİ~®¸¦ʼ°ЮȮƲʸ"ƨશ¼Ȯشƶи†<ؼмʦҰڔڬAB +섞ȮֈвɴȰްƴƴƸԶƪSҰȺ̬޺ĺή殺ʰƾ긌ԠĞƶ5ޘȺҰĮĺ²¶ƞ򢲤欠ʰƪ¸Ĵ¸ĦȼʲҮރޔwzz|xyxywxxxwyz{|~}~z}}z}{~aý +{{{xzxwvutuqqskqlpnjkkkigiecda_agYvXr`f_bd|`[Y_[gk|z~||rxS~~zr~~~~p`~~|x~~pnz~h~|~||~zfphx~~tlx~zntt~rmrtzxxt^|dolhxtbX[WXTUTUSTTTSUV`XY[Z[Z]]ZZ^^\^]]^^eTatUecrhX`d]dfa_dfc`c`afdmpb[cjgeihhhgghjmjgflnmklknnoopqprqonnorstqpqturo{uvwsuwrwusssrsvuyvv{vr~|nu}vwt}ox|y~{x} + zsx~z}{xv|zĢؠƮ̮ʨ´ƦشC̴֞Ī涊М²ڔĶάʾĠ袾ʺªĸʦļ춪‰ԺΪвƶ̀¾ȰԾȾҴּܸ΃읋Bę.# + + +ͥʂ܀̼ʤҰܦ̾ꓺ°IJlȤΰ޴ƲƾʄڢȚȾȴ®侰CήƴʼʶļȢĬ젨̊}ܢмκƨ²¾ĺºƃƬڶʶ葶|w}|}~|ywx{zxyz|~܀{yyy|}~}}םŸ ~~{xxyxwurfsdxhnmlihhggfhedecaadpWuȈb[_Zpz[d[[`bvjr~rz~lhr~~Tm`vxt|xjx~xC|~~r||rfrxz~z~~p~tx|~~px~\nfzj|nzztllbzve\gX]YX[WTQTUVTUVXZ\X^cVVVY]^ZZ\][\]^^]]___Xsn`ag[k}b_i`[afffhbhebgdclqigghjijkjiiiklonljopnprohelpruurqpooorqprvwvuytvywy{vxutuwustxyy|~ytx|rv{yz}{vywy~ +\vxqzvʪxxzxҾzʸȄƀư°}˜ʨڤʸ⏲̼ȮŞȪȾجڞ¾ƀؠĖĺİ ຬCʪ°ĶҾºƾЪȬ¼ζ֔氺ҴļҬȬ̉жҾ5䁆숂Fݡ8ؚ´܇ؾָ̾J֦Ԧڞ¸Ƭؠθ´ĺʸⰸԮμĶΎȾ°Ԥ֪ҺԸ°¼̶ļ⸮¶҃®мȬԚD΢ݾвβָڅ䇔~z{y{{zx}|}}}|}{䆀}~Ћ6Ϳ}|}{xzyxwxwvusxhwnwhjjjhgeffgedfecab^ohqpab^^a`]clm}dJtl|zn~||`Еx|~v~tz|v~~z~Z~~zxt|z|fk~hxnfZzp~lDz~lu`ldq[`SZTTTUSRV\VWWWVWYZ_V_a[\Y]^\[\\[[\]^]^`adRW_]ZcdhdZpThoeageZ`hfchib`Pk{h_emgiklkjhilutlhpsp hqttrtsoprrs8qqrstvwwxux{z|~y}xuwzzxw{vx}||{xsv}woc{uyy~~w~}~Ӱ|}yzڵ~{Ҹ־{̾ҸºȮʰĸJΞ̢ҖζИθưĸȶ֤̦ƴȈĺԼРҦζд®кƾĸ܅̸’ĬԸD޲κؼ?򎛅򀆁򍇈Ӌ ~ƧïԨȾK®ض ƮҬҩز¼ʾ̮ƺ¶άĮ(ĺȾĸƮ¸{ƶȊ´𼴘䎐xȺش¦ԼƼȼ֪ֆʶޅ|}}~}蛏}|z}}~x}~~|~~z|ځٶ4̹ɀzzzzywwwvutshnrpmklljhfgihfeeedcbfmhXtdc\_cz`rv{K|x|ztzv|vvifnxxv|v~~~x|p~(t~|z~z{rrtĎdX~||lo~nŽjzhdZ|pxx`jtmWXWXW[vlZYS[UUSSVTQS[ZZZXWVWXXUUwiZb[^``^]^_\\]]]^_`\r[]g^cckfY`|mdkjaeqdecbgjihne\bqyo_hikkkjkklqqkjorormijlnqupqrtuvwxu4tuwxzx|~|}~z|xxz}~~uv{zzvv}zr̸}~wz}ԾļĘܪKмЮذʴĸʤʥЪƶº蜺¶Ħ}ꨢʨ(¼¸ĸʲƼ{ȸȞВʾ´ʸƾ򕠀ʸľȺβȾʴȜ޲臎ص4ĸ ~܃ڕֺԶ̺¼ҨΕ̸ʼȼ⸼ԵЬƼȶоªдʺ¶̪´ʬĦƴ°̸ƺƾ´تશ¬ޤԴ¤ξʼ¢ΰļ־ƺ'ھҼԺք܌|~~}|~|}~z򂃅4Ÿzz{{zyxwvutpwymi`slnnljghiiheccdddla_tUtxeabc\p]~pmvv|djzrq~xx|pvzzrxrtvzvv|p~xprr~||vzjnvxnvXZpp~~+hp\[YVWW\gxU^YSVYXTQUTRRWYYYWUTUVZXTSy[\^_^]^`]^^^\\^^]X`hk[Q|r[Zlla]hyzh_flingeigdfknomh``m|lmnmllmnnlNloqppp}olvzsotxwutsttuxxyyxyz||z~~~}~}}~}}z{zoqt}zx|~w|l؀|}θ{Α~ؼ̲¶Ģ}ƼʠƑȺڰ̱Ȥк¼IJ̶Ȭ²셠ƤֺĦ°¾ȴ¾̮֨ĸĮ欸ܼƾ̮ȺȪ¾̬غ+Ȥ撤􌇁Pƹ~ꍋ惀ڏʰƤȴ޴薠̸޾֎޴ިƀİƠƼ¶زւƮȼļĺԶ¼Ƽ¾ʲڴ°º¶ļКְƶ̲Ҧ®̲Дpꪴƪļ̶ҮʴȼԾⲼ?~}~}悦}~~҇ɻɿʾ}|}|z~|{zyxvuov}uregmmmkjhigdbdee]kiffxa_Xj\Wz\[ajzkzn{t}n\rv~npTyĖ~x|xp||`~xf|~zz~|p~xzfmxltnHuT~xx|~hm`WXVWX\V[}ZRZWYWTRTUUUVUWWWVVWXT_cY~]Z]\[]^^]_^_^^]\]_fpsX^qcd`eia`s_`nxc]gmdhnhbeja_cjolfhossrqonopqllrusqrtltutxwuuuvwxuuvwy{||{y~~y||}wux}wy~vutx~|}ȕ|zsxsΘ{zҋżތ̼Ժ֬аİֶļΆְ֠Ĵĺ✰Ъ|ªĸΰĬڌ԰¼ºΘԮĸƪԺȺ̸ܰж°ڢʾȼдฺԾƋڋ_ ԾڨHİȶ䜾Ժ޼к̸ȗƺƸIJްԮԶȮоĪƲIJڸº˜ľ¶¼ĸԤԪ¼ʾʼඨԾĺƚ}К̪Π¾Ȥȸ®ޮƪؾʪ|؂z~~棖}@L²Ā}|}}zzyxwu|qpnusoomlkjiigejjfcceed^al_glwUbYc]Ze`]gz|rsʈH~z~~~|~||z|ztx|n|~~fzrpv|~z~zvttxxrjvrvi{br|x|jr||xxagd\[X[WUUWXWUU_]xhSWYZUWWVUWWVUVXXWWWXV[VQd[u@d_[]_^]^^___^^_``T[peV`hhMjjj\vlbduyhkchlfejispmklmjfprrrqpppqLrtttsnmsxxy|vuwxyz{{uuvx{||{}|}}}xs|x{~~z{~v~ʀp}t~yv|ܼ޺̼̲Ɛ椦HоؖĪάȲجİڮྲξ̦֨Ȫʸ®ĺԲȺ츾¸ҢҨȼȺ漰Ʈ²ƦȼԨتִȨ֨ҮмдҲ䈀즗=yL ~ ޅļԮʀ̲̾ڼƾβʼ̰ĺԮȾ¸ĸºƬ´²ľʺʺҘAƺĴξȾȮȸʰʺ¤ƺmؤĸ츲֨ܔعΦºƾ¦ڨҺʮԮ}ڼҾ{|~~~Jľ¿~~|zyxwvts}wfsvpuqoljhbjnjdcdd_k_[l_dTcwb^[^ff]axznlb~rev~zvxhj|#|zvrr~m~rrlzjvxxtv|b|h[\ZZXY\UWWUUWWUYWYYSZZZT[VYVT[]XTVVVYZZZXWWXYVZ`Y\aRib\]`_^^__`__`bb`fe`cljbkle]vjfgikfcorkkh`gmefqsjflokilorrrqqpwwomsJvtx||vuxzxz{|}{{xw}|{}~|~}~zt||w|uy{k}}yuyփƶĴޖоĶĪ쁮ڮʰȾ¼ƪؖꞌ´Ĩƾ{Ȣ¦ҺƴĶ̔A²μƼƬƶȮȼʬ¸„mƸ¼IJ丢윮ڲʰԸ޳􃃁J ܍삃ޞʴ¸蠚ҺʾƮ֢иƾĺª¸ʾºȾȪ̀Ьĸκ¸®Ҭƾ¶ƼФͲ̲ƸИ´ĢĸȠض⛢̶Ȧʤȸʾ̾ޢ8©~솀Ȝ®Bý~|||yuwtxxqnsuqnmookijlkhehkd[cbbdb``bRhwga^_\gYWhn|zl|px~~~|~`z~|tl~p~zj~prdttxrfXn8qe[cWXXUVVVXZWSTUUWXWUSXUQSYYSVXWXZXTXXWVUVWY[XY[[XY]]`b_\[^bZ\```_ab_aa_ahgacdfdbi}`mkjmedinhgjhjvthmvnilkiptrprttxqqttw|{yvttwzB{z~xw{|{~~|{|}~|~}{|y~~~z}}l~wtwvyz̼μ¾¸ΎࠀҾ¾ڲʰ¼ڞޛƺʲĸȺМʲ̼ļʼĨˀΪĴƼƮҨƾĸȺʞƸĴжʼƴڢʼʪԬœЮԲԬơ8ΰ”B +ԌHάμ҄мƼԼ柔ܺȾԔ¾nβ¼ʪȬؠ¶ĺڌ԰žԚʲoΠζȾܼиƪzбĴʦڲľ²zڅ~|~DĽü}{{|zwwsvzvsrotpopokiilhinjabjeb`aa`_a`Ses`X\_`ba\Hvhvfb~zv~|vtz^p\rȌ|~njr~kxc^~xyrA~`unh[YXVWXXWWVVX[YWVVVWXXVUUUZ^_\UVWVTTWWUYZXWWWXY[YZ\\Z[_[Z\cih^V^__`_^^_`dfdba_^jggikhffkiojkqfoiuwusmjs}xkgx}mnrsporvwwsw|yxxtyxwxDz{|{|yvt~}}}~~~~{}z~~~~~zj|~uv怀~}ƴ¾ƴ|H޸IJ~ºʲܚԲںƸΎȬаƬԜ̼Ⱦ¼ڌЬ²Ƣ؞ζ֪ºƈcҺրܷ԰ĭȶއ>~֊ꁃ԰䖛KްЀмȺ̼ڞؼʾļڴ¾ܶĺԬΦʶ„ĸ”ܲŀ̾ļ̾ĺʜº̸ĸȼڒڪȺԬʰਰβώ̴̲Ȭ̰ȶܾ,Ҫ¾Ԝ1} ø¾Ļ~zy|{yzsswvttnrpopomkkifini`blgc`aba`ae\bxgdcXZY^`c~fKf|xepȃfnxrhlhbzh^Ȧv~~|z|~tXJ~``nwjxld1WUUWXXZZXVWZZYYXWXXXWVT`XRZVR`YZZXWXXXZYZ\\[\_d]Y\che`_^^_abaaddbaaguafmmhekuqq`hgdrgmmedltpkjq|znkmjxsnow}yrwu{{xwtyy{|~~}|t|~}|}~|~|~~vz}|}vxw{}ڹ|ּººȤԆKwҶԦxƶԘ𗐸¾ҶȸԮְ֮Шȴ¶ڰ~ȺƸ¸̞ĸƲ¾ƺ¶ܔܬʼذδȼƺ촶ԸґּGԸؼ̮¾Țܴ䬣ƀ썀Š´κҨкƾĠض¼̜Ⱥ¼ƴIJ꺬̶Զĸƺ¼ƢҬҀ̘ƺ̾ƶĪƲƼ쎀ƴƶ䠺ºкĕָʴIJȶƼܜƾȸ؀кڪĶzܦŻ{z|{yursrsvrpnnoonmlhigdfjgbgddeda`c`mYex|i^ZWa^\^jpĚrȉ^jʖ~~zvvlĈ|x~xtZfpp|t{Plvnjtyx~UYXWWVVWXYX[\ZXWYYW[YWXXYYX_P\bNTaTXZ\][ZYYYZY[Z[YZZZ[\\\^`ec]YY^gmdb``cddc^afkhbem{oefnqpmrd{ywqniflikuklvwrptow~|vsw|xuy|xw|}zz{|}~~}}~~|~~~~~~}t}~yxu{ywʺƼƲغO̾¶Ȝ̴꼪¾𞈺Ю̾Ɩ´°䴦ʴָƶ Ϊʖ𴨴´Ƹ¤ľȶʺ褾¶Ƽ®eǘоʬȼ榒긞֟DɀǼ䉻ضܺغȪ´ȔҺĶκ°ȨĤȰθָҨľļʬҶʲԪȸĸȶ̾м¾кĺؔ¶ʮخĶĸҞțʦ¼̼İĸ䬠ΐȲغʒ}|ƶƿ}{{zx}utupquupoonnnmjjjhegjgadeghd_^aX]`baxvg\c]\\b]N|j`|fgfv|~vr|v|zz|zx`n|||hfPt~~n^zn^gu|`fWUWYXVYXXWVVWWWZZYXYYWZYWXYZZYr^R_eR_UVYZYYY\[ZXZZ\YYY[[\[]]^Z_cb^]aeiea`aaa`ffhlh_]`lryujbfoptl{yw|iahpmrspquwyvwxyyxxyyuxzvx}}}||~~-}vy}}z}u~|zywҼ̸ȦЮȴ}ִ̮̺¾Ž|º̴ȴ¢⼜®̶Զ̠ĨΰƮЦIJ°Ƹ¾ʾȶθ¼ޚȼҶ඾¶ڦ̠԰ȸҾƦܞҼĀؠ~ؼ ԴچɼK̬¾ľ֨ڈȠƼ®в¸ƶXȶȼ꼜ްƲЮȺļ¼ºʨ®朾ºʼ̺ƺ¼ĺ֚ʴоʪʮĪȴʼÿz̲ĸڼ~¶Ɖ̾ҲzĂ}􀗒ݼUüÿ|{zxwtxztqtrrsqonnkhnijplcbjccefb^\]aR_g\[ynqU[cYadhKvlrzVċbn|vx~lj||x~lfžxx~nPfH^LpahldWVWWWWYXXWUSWZYZ[ZYZYYYZ[ZZQrNe`XZXYYXY]b\ZWYZ\ZYX\\[Z]^^\^acda_]ba`_acdelhcccgmslgglsrmivgpdctuzyyjkqsqqtpms|vruz{yxzvz~zz}|~~@}o|xtǚsyv~~΀ðľK̼ȸжТԀʬ²°¶ⴖܮĬȦºؼĤ☺ȼĴʸĸ¾ƼܠƺҼҲԺmмĎĄؾʞԐ恮Јص@ Ԩ״ξIļԺ´ζꝤȾĞȼؼʨĶSļļ֠²¼ֲдʢຶʼȸ̺ξԶРΨȰΪԸ辰ܼиΞݨАж7򛳑yͼ~zz{zwtx{ursprsroopnjnhipofcigb_ab`^]iU^Zabx\mZ]`W]d]l|I~grlxS~nʐXzrn|z|fĘtz~x\~~j{h~|twxtVWVVYWSUXYYXXWVYZYXZ[[\ZYZg]SbV[[\^\Z^c][XY[\ZZY]][Z]^]`_]^`bba_`abcegibfggeecammonkhlqgtpxynpjwlu}k`tml{}uwnzypx|w|~}~z5}u}v}|Ŋtw|uzxˠڸԸ֮ƶI}ȸμ̲ҺȰ♜¸¾¶ҶƤSΔԺθ̨¼̰ƞ¸Ⱥ޴Ĵʸָ̺ҢԮƴƼҺشšƸఞԺ|옰ij@~ڊ䂃恂SƴغȼƮĺ´®ºĸƾľ̲İҜʄμƸľ̰ڊĺȸеʠȦؼмĴƾ¾̨ȼ֞궺ּиή¾ΎƾxЌ~շ÷¹¾(|yz{|{uvxsrtrprrpqtsomjfgnri]nd]^cdb^Yg[\][exbX_U[[ZZ[\]^_`S|d̀t|ˆ|~jkZ~Zl\nrvdtlzZn^sevVUVWVUXVUWZ[YVXXWWXXYX[^]YVWYY[[Z\]][Y\`U[rkUWVY_ca[Y[_\YY\^\[Z^^[Z]_][_bca`adiiigdbcdfljdafjjideltuplmqnkqtlvlvtu|wlrnpsvxzvz|t~x{~~|~(}{|}}{|{{vǼ|rzpyywwxyxwxyȽĺȺ̺S֮ƺƸľв¶܎ڮʾºξʔ~z¶Ƹªļֆʾƶβ˜ƾľʬֲ̼ȢۦࠑЃޓ~̮(ր؂ցЀشȺмƼ־ĺҪʶ¼¾زȴľܴʾξ̶€̺Ϊȶ¸̸ʾʺƴಞ¾ļʼ¸Ƹ¸¸ƺڴȖĺƾȺڜ{ĐԀԅPƼ,ſŻ»ýz}yxzxrrptrrrnosqkmvmfhifkodZila]ef^Y^b`]]_bYqhZV^UZXXX]_ba~||Ĕ|ztrpr~vf^lz~~bohahuhr7\ZZUVWWWUUWVWUSTVXWWWVUZ[\YUTW[\V[^UW]X\YYVZnpXb]Z[_``_ZegZU\`\^_ZW[bb]\`_[_gg_abcdeedci`j~x_\mflkegqtnpmoqnrtpqrrvx{yxptunwzyx||{zz{}}~E~zx||}{xy|q}uqypxvvvxxzyޮĀֶΨȸİκʶ̤Ʋ¾Ҭ®ʼԬ쒲²¬¼ʸ̨ȶ´İļȸIJޯξļ¼ƾȾ̾М첆̲Жڀ҄û ~ +؀|·ƑкЄXȮҸڲľ°ȸļĜҺ¾ľĺޞ༾ƶľҨ֌¾¶IJڪථ𰾲°ҸļҴ澶¶̶ʴΒ6̼¾Ģ̼ꂁǰCü»|}~}|yy|vxwvmrtqqmmppmmpqiigchngfeefc_^_c[\bc[[bhvi\a\ZXWWXYZY[Z]`~Xvvzxjjrsn|x~tXk~|~zzxx|z~^l\nlypu[XV\UUVWWWVVYXXWVWXYXWWW]XSU]b[R]W\_Z\_X]Y^`WYbdVY`a]]^X]X_lj[V[Y[\^][]`]bec__cgabcdeedctdYbt|o^hjlmnpplposqjlstquwuqqw}vqyv{}ysv}~}|{zy}~C~|}yw|ަw|xxvuuvwwvvuxzƄX⢼ƬжΦ֤ĺ¾²Ķ̴Ҏܜ԰ؼЦԊ¾ļ°ةڰꪼĶؾԶļҼԘ*ڸȍ-态@»܀z⃇⊒Ԅ1ąҼƼȬ¾„δĮȼ¼ºľξ狦ºֲҾʤ̺ڊ¼֪꼶ƶΨzְ̺ĶĸʸҰ޾Ⲹľ֦еܲzӆ؉Eſ¼½y~|{yz}vz{stoqsqomnoonkplkhdgjemedhga^a]ef^[ba[dga[gYXWWWW\`ck`d:io~|’|jfspzpZj^r~r~nzzvrz|_\p\\TU\TWUUVVWWXYXWWXYXYYXYY^liUP^`T\WZ]\]^XaY[`\Y]`c^`_Y`dY^Z[aecbc_\]b`[\baacda^bkbbdeeeddalmdblrnmijprnmnontwrrtqxusrtvvvx|~ypnxytv{|yw|}}~}|{{|~E{x~zz}v|wڂwvuuuuw{~y|Ȅ:ƸغؼºƲޮ¾º¦Ȯ¶Ʋ}܆݃ƲʨȸȢʸ؊¼κ¼ȾԤ䷬ȼڶЪSҼڸҾزܼ辀"ӄԅ}~E ۀ=ԧڲºĸľξ΅¶ʺ䌺ĴʼҼ껴ҶƴƾʦȀాĺֶʾ޴؜̾ʼܨʈ?ſǿſ}}|zz{wwyuonursrppqpkjknlihgcghhdehgcWinaZ`c]cXi`i\Y\[YXWWcZ`=~dnƄm|j\\}x~z|~x|rBz~bhvtZV`p[YZ\WWVVVWYYYWUVXZXWXXYYYZZ[\msdTTZ\\[[\[\\[\^__`ca[iaee[^e_ba\W[eigkd_aca`ad`]_ceefbcdechllfdiopljlonpqslmuuxwqwvuxyzuruvy||zwuty{rt}z{{|}~~~{}~|~{yy~s|wtzywvuu~uy̸޼=Ȣʺк{¾Ԭ򰮸ºȸ}ؘ}ʺļȸ䌺ĴIJк个̬ڪ̬ʀľڞ„腇: +ۀȰ΀Ƣ޼ؼƼҼĺļļƾưȾ²¾ľľĺذȍļIJȺĀԶƺ̴ִƸк̴޺ҏʼָ֣Ԅڀ¼쏊>:¿Žſ}~~{xxwxxpursquvsppomhmnlkifddhheeghgb]ali[ZdZaX`XVXk]\\[ZYZZ^_[d`j~nʐ~uynhlzRpx~~|~trjgTXXX`[TWWVVVWYYXVUVXYXVWXXYYZZ[[S[nm[T]]`^\\[\_Ybea]]_b[\jqcZ]ad`adc^\`egfbadc`cec_`ggaceefedefkdcj>mhehlmljmqsqvnlompvwmv~}toszwvtv{~}zupzyr~}{zz{|}z|~:|~xwu|t|vtv{zzyxwxxyzv}ҬĀ̼ȼެҰβ̶ļƸº¸´Фؼ팔ȼƼƀδȼβڽԾ޼ľʼܸЎ􂆆⽷⊅6%ɀ~ـBӾ4ގ؀ށִօ9Ұڸľļn¼ľԨֶ׍Һںβζ޺ܴⴶМھȅwʾи¸ƾڸȺº¶ϣؾԼִzࣼBý¼ĽǿſB}|ywu|ymtzzqqstsolmokojfijgigcdhgaagg\Zeke__]cbXj_bXXYrZ\egY_|xzngvzutlzpbvzz~dw~|z~_^ZZRSRtRjXYYZX Z[ZYXYYZZ[yWW_efb^[_\Z\[Y^gc]]`[Zdb__ejfbc[bfcabb`Wdkf_`bablne^cfcdfffedfidhjhegjmhlonqtrmpqy}qlpvpvxvppszvzyupquxux{zvtz|{zyz|~|}~B|~B}y||x~~t}vvwrxzހƂtxƬڬȦЮҺκκҾĺļn}ҢΪчʲҲȬ̴ظ޶ರƒԸȅ_ʺľľ΢~֞~Lľ~~ ۀyٴԇրڶ޾Ҷȼθƾƾڮ¼䉔אĀ̪ĠڬԾ̰Ʀܨ¾ƮвļƲʼ܄džʴܼ,̒Α򉉊DĽøſĿžyz~}yyxxz}uxwutpoqpllolpicjkgjgeefgeccbea[`ig]cV\\acWXYXWWYZiY___^~~Ƙ|rfyvvpqvnpxx|ftbR~,bvdbWWXcpbUXXY XYYZZZ[\Z[Z[[\gdVUdhaZ^Z[cb[]ig\[eb[^lpaXgnhh[fg]\gke`ehfcbcdcjmhccdddfhgddfjkieejpmgllnrtrpono{zrrsytqrvxuqxtqt~s{zusv{}|xxyz{}}}}~}~?|zz}zrxzuvwvuuwxtzzzwԺֲֺʄ΀оƾ԰ҲƬȾĸȲļԦԄҋ¾΀ȺʨĠڮβҞ¾ζԶҾ؂ƈրВޚɌꅅـ̸܀ڀоʸƾƾľȸĶȸґƴʶľʾغĸҜ҄ЮЬ֘ܞľȺܾƾ޲آǪ손䑺0üŽ˹û+½ǹž¼~{{}pxrm|wolqrmlpipifnmefckiaepl\ceea\\cic_ba`Y_Y^__]ZXYZ\d\`|kzlxz~bgtr`fxxnv7^Xb]QTRT[b]YXYYZ[[ZXWXZZYY[\s]]]\X\cbZZa\a\`nma`Woobbf`^]~y``cbljbbkh]\dwh^bhheefbagigdddghhddfjiilpmiiltmlstnmuyljqtuxwtvxzyvsqrwzwuvwus~|su{|Jzz~}~y~~~~yyy|~}~ww|}}{xvwxwwޭwԀغļ־ȸʸĴʼºƾ¾²ҾƲظƶ}͎ʴtؾ¾ʾغƶԞԅҰҮ̌Ҕ഼֠Ȭ܄손|}܌uİ¿úȀ~݀ĶٵƼƌ΀вҴθо¾꼺ĺ¾ľ¼μ§΀첪ȾƺδʾΰкڼΒа܀|{{{}ĿŽ÷ȸž½¹À}~tpwdqqniipqikhhlkhghjiheccfic`_b`\^cda^^]]^bZZZ[\\[Z_c[lzt\xmfvxr~^|XWVTTTV[YSTY`Y]wvYZYXY]Z^ZX[\Z[_[Z[^_]ZZ__`a^Z\aSghTVmrc]_gokbahjctxebgffgf`aec\byzaZjpddcceggebdegijjiijmkikmnmklnmjkoonmsurosxywtsuutvzmvwrs{}wu{{vt{{x{x}v~||{z~z{|}}|}yz}zzxx|xxxyzzxw|xƶ԰ܴ€ƨºθڲ¼ľ¶в̶μƾ䶴ļƾ¼ƴ憾ҼиκȀ欤ȼ̶Ⱥھļļ¤ޠҼܾАϲ~}憢ϽǻȀ~؀ƜڄրֲҾԼزȼƾμԾ¼¾вƾ¾ĺзȼ޺ҾvʾƼԢ쾸޺Ⱦھͪz}yy{ƴùĺ˿ƽ¿~~~{zz~egvqrvrihlomkkigiohhgeccega__bc_][``aa_\^b`]ZZ[\[Y]^YZZ``¶bzhv~Ɔv¼VXvv~fhhjRxvSXUVVVVUZ\YW]ds\X\Z]Z`XZ[]]]\[]\\__^^a\\]_][[`_[ajcV^rnb[aiid`[mXcrbghb`dc_`e`]ftqb]fhhhiiigfjihghjlmkkknppmimonkjmmkpuuroqssrqtxyursywwzzvw{z{{{yyy{{~x~x|{}}}z|~|||yw||}}zwy}~{xxyzxvz{vwuzyzִήм΄ʀ̨¶оʾƲȰ̦ƺļкҼް¾ʬȶƺƾ¼¾ƾ̱~ִƼ̸vļ̚м¶ಬظά5sPʸǼûĺȀڀſ˷줂昁$ؖƾ޸椘¼„ƀʾʼƲȴʈº̼ʾҬڼΆIJ@ꔰքs~}|{{}Φ˳ɾ¾ȼü Ȁ~nz|lgzqesypollljgeiogggecbcdba``cd`Y^_bc`\]ad_[YZ[ZXY[ZZ^\ebbxa$xb`~l~zTh~hpeb7`|sUSXWVVTUVUSUZUZUW`W^acc`XRW[^\[\]\^]]^^^bg^]]^^]]_c\]gh__gifbacfhic}qUmplhiifdeeec_ckmieighilkiiijlmmllmnnljmnomlmljorrpnppnopsxxuqq}zz|{vwz{vw|{vv}{|x}~z|{}~~z ~}}|uz{~|xx|}ywxyxvvxww{y~zڛyԐy$̐|ԮҼܟزԸƼȼȺƾİƾ¼®ȾĴº¾ĸ̦~ܺҼ޴ھ̾ԶоƂԺмR욶r~ͥ«öĸ ľˀՀȠ蠂څ܀ʾ֤ڸʾج¾¾°ܲΗĸĶںͺʀʤҦ}~~}~}}{z|}¦ދϷ¿Ļ̵¸ǹ¾ɸÿ¿E~z{xw~y|lkjzniqiijkkihhigggfeccbefb^`ee`__`bb^^`b`^\[ZZ][Ydbbbg~ˆlfqvȂx~z{u\rhjsnxl8º_UTSSSVVUUTXZYTSWWW]V]dRU^w\lV]\[[XX\a\\]^\\`gcbaaa`^]]a_\cli^^gkf``fknZhWjihggijhffgnj]^nqgefhhghjlghjlmljirnkihjzkljlmmllkkmnnoprqprqqrstuvu|}wv}~xyvx||xx|{y}~|}}~}{}|{{|~~zy{~|zyxxxwwwzxt}zyؘzЀھ̚}ʺЮ¾¾ȼ֪ľ֨Ēľ¾ԴǴ¼ĀºļкœƸʞ~܊ζnƯŴĸʀԀ´ރԀ@ƟȌƴľ„¾ָҶʚƺȄo̾ظ؆†ؼּȶؼ̰ԅҀ}}}~|zz}|„씖<ʕhļƼ̽ͿùýͿπ}|}zvyykrrvmhslkjjlljghhgggedcbfd__dfcc_]_aa__]_a`][[]ZZZYYZZ`]]axb@~fftrsr~~otmTzzpzzn~nRRRSTURSSSRTX[TRTTU[]W^TV`]\Y~Ucb[Z^\VZc[\^_]\_ddccbbb`^\^abbaejfebaehd^7ZkdV`freailfemfhhfeggfjlmlkkmoghkmnmkjtqliijkkhikmlkklmnqqrrqqppsvz{rz|vv}z{||{{{||{|}}}|||{y{}}{{y{|{xvvxuuuttuuxwuԨzԞz@̺ؼܔоҸ‰ʾ¾̮ȬƄoƸֲܞЀԲ~ڶļаʰд԰ĨȺ܅ڀ蒓˖(Ļķƾ˻ſ´΀~Ҁ潃ހԼ̾¾и¾κԴƾƼ䐈҈Ȁ֘ԾĤ{}~}}z|}}~ĪȾƿȿºTzqwyxyefpxpsolgeillhihgghgfe^bdcabbad`]^`a`a[^a`][\][[\[YYY`cz~}abƍd{~|wVVxVRbyXTTSRSTSSRSSSVZUSWWVVWYUZbVcXXZqj_Z\ba[Y]]]_a`^_b^ababccab```aadgjb]`hnmirllr[hu{}tfdmmefghihffinoonlkklllkkllmnpponmljighjlkjkonmnppoqsoqrsvyzyzxy||y{}~zxyyw{}|~}({~~|yz|}|}wz|{xvwxvvwvtttвx|ҦҀ´|ʲԺ澼ľƾʼʲ¾¼ľİʨľ¾ƾȾƺ؊|΀ԾƀԺԮ޺ʌ̶оؼIū2¹ƽ½оǽĿ̀р¿ĮҠҀЙؼʚޮڰȑƺĺԒݞ‶شҠ잊p~~~|}~v»ļľýĿľľ¾ŽĿżՀwqyt~|d|idtemlhefjkhigffhhffcbbcca__`_`a_^`d_]\\]^\[]\\\[Y`[]_a]^[~x`c˜x}fh|xuxvpf|z`qdtXgnxoSTTQSSTRRTUTSURUXXYYVQS_YU][T[g_`a_|Z\^^ac`Y^]^`_^^`\acaabcbceaZ_jh\cbdgggoydelhliezpisrdlolgfklklkjhnmmlklmnnmlkvlnljlqomopnlnsorttvyyu{xz}}z|||~{ww{~y}{~~||}||}{z|{yxxxywvxwwwvtytvwywwtȤܨxzĀ޸̸“ҶĔثк¼ЦƼ¾¾¾ľȼ¾޲ȌȬה܀ΪܶМ┅p򌶫v»ûǿƾ΀~Ԁξެ؝ֺȄԼʌ̾ܕ۪ĀƄܬо괍~~~ǽ¿ƿ¾ʾ½ſŽ¿ļ˸؀z~{ywyuvowufvjmolkllhigeeghgfmd_ac`_a\_dc_[_fd^XX]_]Y_\[\]\ZYZZrd|ktz|hpyTrfpnjVUY^SSTSSTWWVVVZZSQUYX`RX]UYaYX^ZWcaYgY`_W[igY^\[^^^_a]cec`aba]bec``dfedhke^dpqqjgegmjrmwkixojjppidgfghjmnljklnnmjfdjjihhilnnmnomjmrnlnomkourttrswxvs{}wx}{|{|}x|z|~}|~y|{w{zttxzxtzwvwxwutss޾Ή{ހԼئҚдо¾̼Ҹʺ¾ʲȸ޸ľ¾ƼЏ֠Ⱦк‚ڨμઈ¸ƽûŲƼҀ <޶웪޺ڈμ̾?ƒھԶо▂օЀ上܁ܾ}~޾܉z}~}|}}~*µµ,ĿȾо¾<~~{u|uvon{}pimmomjhjjggghhfdbgigaaed_``__`a_[[^`\`]ZY[\Z[]][ybzzktrl?v~zJzɀvlj`x~bck\RcTTPORPQQRTUUUSTUWYYXWZYZ'[\^_^[Z[[]_aba`aa_^_abdaceea``aa^be``gicbbceempjehlngly{y|znqojehhhnkhhjkmqmkiijkjikfeijijllifhlonkjjnqssrsurrtvvxyuz|yw{{}~~z|~,:~~|}}|}{wwz{w{xutvwutvvt̐yҾҪƼԺ■ľƾº԰ЃIJȾ´ʺľ?ƺĶβȬи¾ľľIJ֑xЄƀ¾ҾܰࢾҴ샄+¾»Ľþ,¾ĿżƸʸĿπ1ꄁԕ蠠غҥҼܸаƸ̾ںʸ̺«·ЀؼIJ戇Ό}}}~7~~~~øƺȾĿȿ8ýżν1|z~xnyzjstigomcms`mjhffgggedbcdbba`^[[^_\_][YZ[Z`]^[Q`u`dhulnx:m\ptmln_WWXSSYZUPQTTRRSTVWVVZZYXXWWWYXXYZ[\]]\[ZZ\]]^^_`aa`_ba_^^___aabcccaafalui[_hfgggfeefagmonmkhnnqu~cwifpqgbjghilmkkmnlkkmmlkkhgijhiylljjknonlomiinuuoqqrssruxq|{~}u|~1}~}|zwwzzwzxvtstsxuwt؄րxŒw{ܖΰȠƲҮҶƦؼ¸ֺ¼Ƹаƶ°ξ¼ȄƀԸԾֺIJޔ܃|¹ȸž¼ȷžƾπ>ǵ;Ї·ζȓĬԺимҾྀ̬}}}}ꀇ/}|~~~Guɽо˽˻Gwtnynwspqnfltlepkifededefabgf``fdcba__^^``^\\^^\;ZYX]\^[\^[dZ\egz|~utrƊdcp~ebsavTTWU\XRU[WQPRRRSTVWWXWZYXXXZ\]WWWXYYZ[]][[[]^_```^]aa`_a__adedbi`fppkhdcdfffefffddgiikmmnmkqspmijkljiehknnklnonnnoonmkjkkkijlllnooonmlmu{sjo}npssqosyz}}y{}x{@- ~}}~||{ywwyywv;tsrvuwtuwt}quԼ}Ժ~؄΄tª⼼оȴȼĸԞҴĺ¾ľȾȴȮмƮƲ̾Ƅ/ܺ枀ȨЅn⃔ A{Ľ¼dzŻƴʼþ˽ý½ŵż·р=ɻ(ֳހ܇̅ބqЪֺؾľضȾ檼̄6ψ΂Ā҆ЄN쎄ґ}򂎃}}~"ƳĻɼ@½ÿ˹ĻȾȺjxz}ysxzttulfmyieqmggghgfb`gdcggcaaccba`___a`^]]^]\[\^^\ZYY^Z\^\Z_jZ\ag~]ecqnyor~r6zg_~_(pfsXVTSTcXT[XaUVXQQRSTUV"WXXZ\]YYXXWWXXY[]___^\^]]^^^][_`abc`_`acedcdccagutcbcdddegijebegfjqljopikyuyvkgklkiklmjilrmnnooonmjkllkklnmnonmlnprmjotvqmqqstrqtyzv{{|@=~~~}|){yxxyxwuvxxvtsswsuwusxqszޅu޺|zмĞԴȾƴ֮ؾʮԼޞ̶̲¾¸̬μ¸ܠ޶ξԮƼĄ0˄ƾ~Ĉ܅Mޗ쁈"ӿʺ≮ǿǶŹ´ŻĶҀ&»*̀YԁȜުҶЅڼ̄9趀ꎈЄ:з΄\܆}||||~~:vȸĹƽ&w|z{sw{vuqflxfknihecggedgjf^bba`*b`^__^^]Z\^^][ZZ\[Z[\\[^a`Zadgw|__|vjm|~|h|cpLz:lix\adjfaZTUTRs[R^R^^RUYURUTTUVWWWVXZ[[YWXX]]\[Z[]_`_^]^\\]_`^]_`bcd:`bccabceainc\hogihhffgjmffjonhhmojptpknozvzujgnmmnnkhkpmmnojkmllmooppomllosuyik}}nusqsutuxvu||}~&{~}'{yzzyyxuwxxwuttutstuutwzysw|چ܀wwҺΚоެꔴؼԸ̶ش~̮Ķºڨy̼ȸƺވ|ļڮƄ:ظƳئ܆+拉+ꀌ:¹¼½½ǰÿĽųĽƾĿȼҀ :*چΆޔ܉څUЏȫҶд҆ƣֺƄЄ:ؾ͝ʄHΆ↞➒ꉁ{} ͿMҼÿżļ:|~suwq|shvml|zgmvcmkgfefffegebgmjabba+bc`_aa_^^]]^^^]\[[\\\[[[\_hZppcbcqoviVoxv|Ĩzm|j~:x|ZrhdEhihiRQRSRPuh_WWYWQSV WWWXXVY\\ZYZ[\^_`^]\\\^``_]]^`ba`aabccba`bdedaacfgdikfehgnmkhffhjghkljghkmnpliprhypnv|xojnnprokkmoopoooqrlmmlmppnqqpnmnqtqums}urprtuuv{{y]x{}{ww:~~ ~{z||zyyxwvutuuutttuxs⢬yz{܅ȉʀʄVθڸؤĨƼξĺԴʺͦƆƺؾȮ־Ƅ:ȾδǗ؄H֊ꌤ衖m³ƺýºķ÷½¬öýͿ·¿ƾـ +<)ʧ҃ܗڒʶԾܾΈڴЖPԼʘȅ̾҅t}|ƃ؀~{zz}}{~½DҼƼ¼<}|qz~np{rnnqwxkgrljgeefefeeddfhhgeddcb)da`bc`_`_^\]_`^\\\]]]\[Za_d]~nbinotnwcptv|kzZwzvNzhfz_n\XZWP[BPQRVYOTUrYuVTTYYSSVVWVVUVVYZTWZ[ZZ]_YYZ[\^__`_]\\^^_`^^_aba`deefk``jmljejjjhfeefkkieeikjijmhdmvshmmlr|xmpooqpmlmoppnnoqtqpmlnrpknnoonnoqtkn|slnsqrtssuxyzwv{;<(|{}~{z{zywxyzxvsstttsrqxv|uw~;ރоČxڴȉʼֶąƀȰζΰ̦ضʾھĊľȴмƔąҼڄṕ쁌·Ct¿ҾŻɾü¬´ü¿ɾ¸Ϳ½ž;؀ P֬ʂ手䅊“Ѝڗ/螔ԅ8ʄ܈΀{~~}}⁂zz}{~}~{}}~}|8Ӽ̼οտÿ(~{ss{vnrpknoikojk"jgegeehe`bjhgfedccbeaacda_aa_\]`a_]^][Zb]XZ[}a]fhebgslmuz/bvj8cZTti[qYVZSQPPPPONTTPYVSfVW[SS[]VTYWVTTSVWYXZZYVUX[\\\]^___]]_``_\[__]_`aa_``aaabccfdacehgeefjgbhkedfijihggilnmprmflcfqqmllqortohn|tolllmornnnlkmpttrnlosohijmnnmllowreltssvvsru{vw{}{~:}||~|z||wwuzywutrttutrqytprrvu|~{x}ܪ̾ضض̐ƸҶомּȴܒʺƾʄ9~څ܀ꅊļ¾Ͻľ:ǼŮ»ľʻѻƿ˼Ŀ׀ +LҦ臊⍉ހڌ休֞؀΅ԀʲÖּȸȾ}~芃샅}z~z{|}}|z{ש˶˼Ŀſ̼M||yxvsrsmpqnlmlingfklgehfihdeggdkifb```a`he\hl]a```__`a`_^]^^\[[Z`|cf_adZgc]kkss~b vjjZ~~b_\Ycj_XUWUWRSRQQRS[W\]SXTRWWSSYU[]ZSU[XYY[[\Z[]]]\Z[[]]^^]^`_^^`_a_^][`abccb`_jd`afheacehihhggifbfnjeieqmai~ybjlmmlmoqomnqqos{unpofinfjpqlkprpssrsomjkgfonlmippnnpqqqtwquqqvvpwq~~;~{~uuyxvxuwututsrsusrrqx{~wz}sȀ|u€԰ξʘļмzܼ־%Լª־΄̀ޚּȺԁꊃ~ܪžȹÿιӿîŶ½¿ȹ̼߀H¼Cȯ܆ދ֔ҀғaฌȆΘ҄LŤдȜֺ̄>~~}|{{z{{y{~~{x{|z~}}~qծHwzvvutuwqqolmookhmnhgkjdgjigfgfceedddccch\ii\aebaA`ab`^^aeZZ\^^]]~chcam\nq]pla|p|hĜ\vf|Lxhjzb>f\XYggf_SXWSRRTTUVVTTWWTQTUTTY[\YWWXYZZZYYZ[\]]\ZZZ[Y[\[[]]\`a``aa`a`abdccbb_adedegifgihgedd]ppa_glnhgghfgq~rlggkmjgnnosttsrqpsspppjijlmlkjmklmnmlkkkflklnhqmomopqoqswsywttnusǣ}y=uuz}zwEvvttstusssvzoqsuusu{Ȁ{zvuคڸΆЀԬܾڼʺIJȶƐмެ1昜ҶʞԄ>~rԬýûǺ¿ʸžƿ؀¼ʉށʔ:򺄗ʏ܄'܂ǰ޾օԀ謘Ѵά´ھ{}}||}zz|~|{}y}{~|{|}}ɹþˀ}{wwxxvtuwsnlnpokgoqkhjjfgjjhfffcabdfffdckZei]_dfccbbbabb`cda]]cia^\\]\[^]cry{~c][gZ^jo}ql_:~fyzq'e¶Trtxpzzr|~`daYWYclV]]T[URRSRPRSUUVSS:UXWUTVRY[VSXZVVWXY[[YWZ[\[XXY[\]^^\[Z[[]]\[^^]``___acd^_acdadggecdffgggedeeebclobapihfintojjmoliijlnrvtpputqspkijgjqpjinooonmljiqhjjnqijnmnoonptrovvuvqvpjlƕxxy}$/~Su~vx|~{{xxxwwwuxyvrrx~xusststwv||vtsvބډڬܾw҄:~¾ľָҼ΄'{ԴҲ΅̀ܤвҰʼꉉ򀉃Ё؄xм׮ҼܜԴꀇ;~{x~y{xwxy{~~|{}}}|}0ܵ~~zzwy{pprsqnmoknlhjomeihhjiggigfefgcdvp^dg`feedcbbaa`_adda]\]bfc[Z^^i`Zmb[]fd\\[XdZag\xg>f`xzp~UZUTUSSXU^hXOdgXUYSPUPPRTTVVW8TWUTZYRXUW^^WSWXZ]][ZYYYXY]`a^ZXZ[\\]^_]__^_aa_abcdddaab0abbbcfhfbadifgfedefgngeqwkdngjkjkmlhhggjnolhjorpnkpnlnolllpoijqqknonkmqpkkgmkkmiolkkoqprtwqtrrsnr}srxw{~yy~}}|yxxwwvwy||yuuv{|tswuڀwq̄yrt}}uvur~tzqء搰־欲~ڸ}{֥κخz쀅}ŸݴûC¿"삉ԅ腱bҹ܅܆ڀʨв}{􁆆}}|{||}~~|ywz|~~}||{|û&΄؀~yvuuuwzuqqsrpoqjnmghmmhlihihffifefeefggharqdhhbfffedccbcbbbcba``]`ed\ckZa]b\k^`YX]a\o_b\b~bi~|]XqRWWSPR[ZXW]daWsaQQWXURQQSTUUVVSUWWVUUVYXY]_^[YX[]_]\[[Z[[]\]^^ZZ[]^^`a`_bb`abccdcdddimjeabeghhhgfgidomht}qahomglumZkjiknoljhhjnnmklmlnnknqmponnoqomqsplmqoimhnmmnhmolknqqpqqlstvxpsmr~{zzv$}{~}|{yx{zzz{z{zzwy~}u{ރryuztڄxzsrw{uvyuzքbԞԺ޾ނ޶±ЄԈҀ̺غր~}ۿDހ򄀉􀃤λހ¥ڡ־|8}~xx~xtw{zyxy|~~~|}}D˿׀~{{yxyxtrq}upqsqoomiimkfipokhigdehffggffeejgbewtaihggfeedcefeb_]^_d\Z`da^aYcZcf]Z`Z\Xa][`^[ZXrZ]~g^wc_d~zd\lUWSQWLW\UPZeg`gZQWXPTTUTUTSS8STZ_ZQR[[ZWSW`a[[ZYZ[]]][^^\XX]b]]]\[[\^\^^]]_`^a``abdefdeffcdfikjfcdfghhijkmlhgihn}qkilgbgsfjmlhhmsxplmmigimhklju}ojjprmjllnlilsrkqkpopqimsnknqrpounstwxrux{ys}|w|}vC8~z~}}zy}~}zwuxy~vsy}zyzq{r{~urxrtpywuzxutr֌tt~w֠ڎzĶxĸ޾|~z~򎖉¿µºĽDɿʿʼӽ 8ԁ΃ʸ_ʂڦ؄ڀָԡ}Ԇz쏎{|||v{y{}yvw}~{y{|z|}~*8{{~{xwwvuspyzqmrurlmmnmlkkinpldafmhggalj`gxs\cegggeeeba`abad[db`bZfef`Zd]c^X`]XXYX]e_nx]]YZ[atnXaz~lt_HhZa`ZXRUVQZcRJa^Z_^QRZXTUWUSRTVQRUZ^]VPWXZ\YVZ`_\Y[[Y[^\]\\]\\\]^_`_`_`abcd`bdeddfggdcfgedemidcjpldghihggghhijjkkmntumnlinkihhkookgnvpgmplmihijihmutgiookkoqqqpqpotmjmqpmlpommnqtvpnoqrqruu_wz}yw~8~{zuz|~~}{{{xxzzyxyz{~u~|z{s~}~xr|u{xs{wrrsrxzΆuvstuyֆԾ䀸~ؤ^ܥ{ȶ\ùƻļʻĿ̽  + < 􋂇켊ލ쀊숈ȀйȌڄϕʙҍꈖ+~}~~xxy}|yvx~{z|}{|~th<}~~zzxwwwvusuuvwtnnspqponmmljmomjhhjjiiihmlkifemwhgggiigeddccbabbcXeaaeZ__\^daX\]be^[\Z]^ZXY\YV\hZ[XdZbbv`e^o+jb`__ls\m{ivao\VZhRSSO\YR[_VbQSb\WbYNXTVWXXWXXXWVWZ[ZZWWX\\YY[^\Z\\Z[^]^\]^^^]\[[\\]^^__^__`abbceeedeeedeggefgahkgcdikfgggffggij?klmpsmpojnjmljjjlmmraooioimkjmmkknhysilpnijkjknrtjhnvrgiuooopqhrqsvvutuwyz}|~}<~~~}{zz{{zyzz}r{{~rwwtv|yptu̬z}yvwuwxtrsvtqwrsp{qy{z~ܞ޴vغڇij*zxڼxxڅԺȎ؀򸔦ҝꈖ쁂~-ZµhȽǼۀ + 赊ְ̑ڀֶހƆғނwŠ~}||~}zxz|{yz~|}~} ¼2;|~y|zyxwurqtrsvupnqqponlljjllmoonkhkkkjXmimsl`fxoliilmjfffeedccbb\`bjd_`_]_dc^^`ea`WZXYae[]]UXXVZceWZ][[Xa^axloa\^rm”iftozd[g[YaYSUWUQSWYZXX\_^[XRYYYZZYWV[YWVVZ\^\YXZ\[YZ][\]^\[]\]]^ ___^^]\\]]^^`abbccdffedeefghihefidgigfhjkfghijkllljjkminkppjmhmmljijmphqe`sqbhomloqokjeluwpjkpxpijprmggopjkqphnprsttrqrqsvwtrsxzz{~2,hw}}}~zzz{{zyyxztxzւ|wxwuw|{vvx}yģxoust|vwwpssqu~}orrtt֠ra¦yvxΦzvyȨ…sąږЏ#ýĺǿʅ݀  +» +솁⨈Ψ +ȦԼΉT܂~҃}}~{|~}|{|}~{zz{}~~}~þ2:}{{}|{yxxwvvoutoqxwoqpnlllkkmlmoppmjkklmmkloonlkjqnkkmmkiihggffdcbd_s\eb``_^ae[\_`fmhWUym^Y\ZVX][dffb\[`d_ +~sd5]b]cĂvb~rkdi^^^TTdfSWVa^SZ_TY_TXXVWXYWVUXXYXWXY`][ZZ[]^]\]^_^]^_^`_`bcdefcdffeeefhikkhdehhgggijjihijklklllkklmhmjpokniijklmlllirzqcitolkknqqolofdosnkofkqrompvumimoljknpruwvvusrruusqrvyzx}2<~~|}|{}||zyw|wܧt}zxxwvy}stwx~Ф҃rpxsvuqsxv|~~΢wstƚzں|wܬ|؅ڀw{ܾ{ڀ”֏N܇¿þ2ۀMľK씧ܞ ܂΄ҡڀҀȃր渨|ԁ𸋐摅{|~wx~|zxxy}~{{~$2Mzxwwvvvy}swtmpyyqsqoopqppmmnnmmmnkklmnoopqsqjksslonnmlkKihhihfdm^]dv[ce]Z`b_i^Uec^VybRdci^a\XYYWYh`cZ]Z`]^\x]^J^br]^_r^\_ie~xe]`\\_b_`\nlczlrqeb`YTUW_aYVUW+U_W[YS`VTRTW[\\SWZ[YWWX\^^\YY\^\]^^^_^]]^_a`$aabbbcegggdefggfggghjkhefjdhlkfcfkijklmjkllklnokokppmrnjiikopnkqhp|uljhklmmnoopjrrigotrokinrrppktwoimqppqrstvwwzxvwxwwyv{|yy~2~|||~|{|}|zxtryЖqz|tqwyvul|zum~n~̄x|wsturtx{pҜqoxuwu‘vwڄLvzҊuvwܴwuwځ}Ⱦ}u{xxθ{|zހ|xڬ,^x¾¼܀ ƼҔ܄觃ڂȗʄցԘʁ€{zఖ€~䋊{{z{zxxz|v|}|{{ *Ԁ~z|{zxvtuxz}wrttrruspooqrqononmkknpklmnoppqlosromnomopnlkklkjiikjgebkngd{]\_dc]\__jg]V]^_\_`YYfp`YUY[WV[\V\]fYyd[[ZYZ`nk^a`]`_]a___ctzad_et]h\_Z`z\^vo|\pvqbi\Y]baYVVSTTXWVXWUYZXWVXWYZYWWY[U[`_[ZYY\^_^^``___`a` aaabbceffgdeghhdegjigimkiijjhghijkkjjkljklnplojnnkqnplijoqpnnuji{sbknoollnoolpqmjknrukmqplikopnnqqossrrsstu}zwxxxy|w|~{z~*}||}~}||~{yv|yҖtsv{ztsvv~tmtu{x{{ttzsptvrrwwqtu|o̍yputtsywyxuxwҜvzxww{Բy|zĐyyzu|x}贎|ܼ꺛̆ µþü»Ž)݀% Ǿꤝk܅ꀃ胊ޕ枣ʁԢԀʔ΃ܮ||샶ℛ~~~~⎅~{||z{zyxwxy{w|~}~}#N>Pb$>΀}~ýſÐ k耮ƿڀӅRǦ}|zsvxtut|}uz}tzywvuvwxuxyxxxvs|zxvvvxxtuvusstuvtstvwtqqstsrqrtrrsuurrsronpstqmorfp~^~wklljjkjhhijigfffhghghijkffefhiihcabefcbcifefedfhh^^knb^ebkkcbeddngScbfae`nc[kagenv_Znbd`ekQdY#`_accaacbcbbabbc_eecccdii^_kh[`rbcefkgfdbeilkheegjiffgejoojglurko{zonxstymw|j{uxzyz}{zz|~~~|zyy{}{u|}uy{oese}wwfnhyptsqqsuwwstuvwxxwvvx{~ttqpprptReR`jpXdzzͩz~~zz~zp~{|w~}qz퀳»ŹŹſſ̾jڀ}fn2 8.*ˀ }}~~~hſưǥ߀¾ÿzШ蚸hm{~szyss~zxvyxxxwwwxuxxvuwvswvuttuvuttqpqrsrppssqorusqpppnlqjknkyhxpqoljmmlhikjhggihhggghiikihhijkihfeffdegbbfijfcci`_gkfcdg^jn_cl`b_oiYiYmagi^ka`ce[suYchclbY_]ifbadecbbbcccaabdf^_ggdb_ehfdjrj\deffeefgddcdegjlkhfhhghiefjmkhjo_zpn~iszopty~l{vz{x|{zz|~{}~|{||{{yz{{yutwt~klx~nhczwwutsrrstuvvttuvwz{yww{~ycooOq'ç~0zܔܴt(n<~~~~zyz|}}wy~uu|¿Ǽ]ބټ4D08x.,48,ɀ~~~~~~~~}~~+dž¾ʴQŷŽOЅ"ج贘,ϲxv~|||zuw|{yyz{{zxwwzyvvyywsuxz{zxwxurswxwtuvvtqqtwpoppqrssvqostrqsrrqomnpqjokqlbuipqnjiklkhikkihjkjSiiikigffghgkhfeedfiigefgghj_jka_ild`dbfne_jegbcgdlbci_fdb_kam`fydmhekgbd_riaaefffeccdef_bd_cjfceeehlidfedcbbdfdgihddgkniddfgikphdjrqkfmnv|rrzrrwtpvw{n|vw}}zxz}~~}{~|~}|x}y~}~}}ussyxo}nsvwwvuvwxyzzyyz{w|}xwz~limrzM"̑tlȤxԜ,α~Sy{|||~z~ž¹¿¾ɿĿϽE݄!ьHt@<.$0<ǀ ~~~~}~}~_þû_ɱƷıüɻƿ>؅aҜΨبĄȸؼ؎^ƭ~||{wy|}|}~{wy{{yyzyxwvyxwvut_uvutstuusrstutrsvurqsspl}qimptkqomlmnnnovvkeosgnljhggiigfggghhifilligffikljhggicjfchgenheehgcemeilg_dh``fjbbdhhedfedi\ml]Znejgeifdoodbjjaggfghhcbeedddefgigfeefko`hedmedd_igdfffcrpkfdehl`b^vlpnfzeruj{uhs}qt{stuux|zx|~||~~}~~~~~~{~||usuxwvzt{vtvu{zv}o}w}~}yy{wlsŽ8@\zxhdT <$TbL~}}~~}}}|~~~|~}~||}~}~~ZՀмB2ðߓRʷûϻ殹ϿǺǀ󾾺΄Tֈ`ꀀꔚ삔¼ȸ̘_s~}~}}~~|{zz{{{zByyyxxyyyvwxwvuvwwyzyxwxyywwz{yz}~mxuxtdzxvuuutttqrqqvuj|xrljkmpllm0llkjklkllkjikllomkljgjrmklopompljlnmnqdykdp|dqmlptbjndgpghhssr\tkkhgjmjcutpkedfighijjiihmihjkjjjlgnjff|^psqrhlmgijkjjklqmogj}_|c]pilongryxsqu{vo{n|syqtuuuy{z|||{zz}RsvpɕT_vyĤШ\ tT8hphhzlpPln"u9@x2}{{uvՉ֞˿ƹ·ôȾƾ¿1ՄׄM쀀p sD @0"&ݷù¿)ބ܆b԰莤Ȕ̆伈̞Ј舔؈T +  }~vr}}}~~~~~|~{}zxvvvwwwxvuvwwwy|||zxwwx{yxz~}z}ɠŲˬ6ӿοswpV@ZdD\JNxXd80HxPphd`xt|ЀptT>(Ý̽޴Ŀƾľľʼʿ:Նvz؃pP,JP 068\DLx@0^4@ ($4x84`ƀ'}~~}~~}~~}}~|~}}}~||{~~~~}~}~}}|~~}}~}|}}|}~~}(؆:ÄNÿ¿Ʊ YĽÿѼ½̀ǽ.܅ބ܄߅rւИ؆ОਂసƐ༘ОܢɆ(<]tv}||{||{{z}~{z|~}|Yxz||~}{zwxyxwvwy|{{yxyz|}~~}~}}xѭîk7rͮþɲњٸB l@,X(V(dpX0hޛx8bd@@t@ppp|htĐh(^XĽĻ¾¸÷ξǼxջ¿3ӄ҄Մم`޿ľX(pB`B>@`@D̒H dd P6@XX66`DR>~}~~}~}~~~}}}~|}}||~~~}}|||~}}}|}~~~}}~~~}3ۅ˄U½„½ү<ʺ Ć„¿¾;.܄߅ᅮų𳨬Ȍа฼Ȑࠀ𬆀҈쨴Ʌ,. ~~st{{{yitv}zrv{ywxz{{z|}~}zy|zyw{|}{yyzzyyzz{||èDļoɿӻ˛oeÕϠٳzȯ$H0\LXvRpnX<`$H@PHn| xڴ0@@pȔtdҶ\܀4 iֹ̿Ȼļ̲¾گ*Ԅօ`zк٩{qԦñպ((@@g6TR>P 4$\\d $$xRj6*|Pp }~~}}}~~!~~}}~~~|~}}|{{}~~}~}|}}{}}~ ~}~}~}~DžÿнzýÄĀͿļ¿ ބ߂ +ބ̨ܝپΆЎЀؘиРԂЎĀDx~}|{0x||vvzzu|yvvxzxvwtuyzxwzxyz|~|{{y}~D~~}|{||}|{{|}~~ŦĤ +Դwqxٷ֤~<,n,$d `P0Ʉ}¾ԇԄمۄ߅݀ڨ懖弴һʤȐԠؤئЌа脄ذĘ𼜔 ЈԺ褨 ąT~}}}yxyzw{|xwz{{yxvy|~wyzzzyyy|yvwz|}||~{{}~H~}}{y~~}~Ũý˴iTmeϹֺtj 8NvR @ x08hdF`lp@88XH@X<,${}z}|{|yz}}{{z|{z{}}|z{zzywwyzvxwy}~~~~~AǓ þùŻա{ff|yeeZp˵4 LpT` Xp pw|h0h0HX 8@`x |v|xpH |\Xhhxpt ÿc˻⮸ý̂ͅυ ԈԄ_污eNbxpbd[rֽD0|@|,<~\mx\L 8x((0h8<@ 0 6 ~ }~}~~}|} }~~}}|}|} |}}}~|~}~~}~~~|~}~~}~~~}~ +(ׄVɄDŽ +8Є=οعׅބڄޅج̦萕ڙҴФ܌Ии괸҄ضЀд +lƄÅ(V7~{}}{~~}{}~{yz}~|y||~|zz|~zx}x|}~ 8ĿyϳɼʄhpØ~b|jlnrv\s|$d8h΅ͅ@5ú3ޅم߇珥ΛؔҕҸ̈Р谈Ьܜܐ螤 +I@U~~~~~~}||~~~~}{||}||~{||<ûѶvqƛezxx`\ejXĠzoua\ÿʽ؄ +ˆxۅ".rƼ漝٥ؠࠠ舜ࠠ؀؀ȔִРА3CX|= +x +FĿ΄{sqϞ€ptj@lqb8yvz͐tl0p 0XHpp``p @ trPhHh00@PP`PX~¤t|䔄pp$SY= uþψT݄Auq͘r|HlLFv<`uVkJbXkhfk @HD8(8Pfd(0hP  h00z\06XjD D~~}}}{{|}~}}}|}|||}}||}}|||}}}| {}|}|{|{}}~~}~~~}}|~}~||| IĿηнԴûJþфӄ քx߄$ԼҬļరШࠈЀкజ蘸Y~5 x$Ą̄̀xptczR`ztqZ ʀvzlyҷZdlx@0`P`Hh8j@X8L0hrP@`@ @` h м |XpppЄZ5¼ľ0Մޅ=wnqWj*4^Hxa]Bpa<@cJgq8'hH 0\P@0 @dn`H@0H@ $$<hx4p` ~N~~~}|||{}||}}}|}}|||}~|}~|}}|}~~{}{||}|{{||}zz{{~{}~~~~|~}|~~||~~}~~}~}z}ZՄDŸ»ǿ㼶ʽ¿*ֆڀ2ܴꔜֿ䀀Ѐذ؀М𔨘̸ +ƄZD|*…ĄƄɄ̅̀tt{uvo|Zb\h~XlNt}zT(X@T(`8H88PX`8D\|@d|0P`p`@xp|zܰP 쨨P p@ZD Ä ÄFӅՄׄڄ܇:vv}vulfA,,xW~dBI v]fV<GHD(H$dx` @D(8XDp0PX@@XprD ZN`$z` dP((\C~~}}|z}z||~}}}~}~}|}}~~}~~~}{~{~~{|{|}}|z{|z}z|||~~~~~}|~ +||}~}{}}z~yمׄG̼ÿD¿Մ ܆݀!͟ɗӋ쀷‶Șؠ̠@@`ࠀȖФȰԤy\, +v$ĄɅ̅΀szlyxrӥxf_(dѬhZfml~p`b\``` ``P H0HHP``h8pP` 0@ ``@Pv܊xp`x`PȜ08 \D +ž¿ Մއ3szkxvmʙgt:zEJN:D[m"it8(H8 t ( X00  0`PhB,z^pH`~ ~~}{}|||}|}~}{|}~||{|{#zz{{{||{{~~~~~~|~~}|}|}~}||}~~" +1؈S͵;ƅŀԿ¼ ׅ#܇$7Ʉ„gҁ·ج郻ȸȄИ蘄p```ࠜ수蔄謘ؘ˅„" +4SƄEw&ŅƆŀst{ux{qm\djZyn,tl|h`hhH\H@XPX 0(x(LXXp @`p"@|Ԅr|ȌxPl@Ht`@X؄ 8SΌDì˞~ͼ͆&օ׆gru{tqrgcBJP~{ly=jNVaJ8TL H(, hD2  X0@ 0@ @@0pdlP,:0B|@,(T0 +~D~~~~~~}}}~~}~}~~}}|}~|}|}|}}}~}|}|||}||{|z|y{|{||{|y{|||~~}~~~}~||}}}||}}8JօՆ̈́ˀؽʹоǼc¿݅怀؜ݪԍܠߟĸȸ謸谬Ȱ਀Ҁȸ؈8IeʙdƄDŽȆ˄ +΄Ѐtstz^xng׸jΓttpPl\Hh\x@d8T08`P8Llb<lX@h00```tp @|p +p t8Iiͼդdļľ¿ˆ҄ք؄نׄ]rrszLj\RjNgv`eeR>z  \((PH@(4H0VRP(P0 hf,HfF@4l0π  ~~}~~}~}~D}}~~~|}|}|}}|~|~}~}{|{}|||y{{yz{{yz{yzz~~}}~~~|}|~~}|~}}{|}~} ڄه2Έ_ҾܹҸԄ]݄&߄ KΡ̪ĬꆠȜܔмࠌȰشЀ!Ј䌼شфÈ 2  &„„ͅτЄҀyytw|tz,hNxHL4DT4цh8H`pȀ|x|XhP P\TX`xX@(00@@@p@hИlظtt(2˩l¿Єӄ҅مۄ-uvruzrn ~tnBB ~jsfHv+(DH4D8@(@0@`X`0`p,0@(d@  (0 +pH0h0$4\Pph|Nx|D8L0 +~~~~~}~}~}~<}}~~}|}}}|}||~~}}|}~|}}|}|}}{{{zzz|}{z|}{}}~}}~~~~}|}|~}~~%y˄̅5ҾɄ1ƺǻӄm݄y𞴌ԈبЈȰܴİȄЀ؀ȸĄ1961 ÄψЀ{vyvx8d\P(Dppt(<`f|\||| xPDPH@x00x`|`PTX8x0` `p@@@xPP xtPh`ܨh|f761Õĺ¿û ҄Ԅ؄ۈywruru4$m $ &nX~`< $0 HH0\<08x0X@PX $* p0X|4(~}~~}~}~~.~~~}}}~~}~~~}}{}}|~~~}|}}||}}~||~|}~}~}|}|}|}|}{||{}~~}~}~}~|}~}~~~|~}~}~~~(ނ݄'ڄ׀ٽѽ@ټƄ(<qश̐œܮܜЄİЈРĊ𘰰ҠИؠά¿ 'Dx(Ä$ŇƄ҄ӇҀvwvwuwwwy}}0H$(dl`p0*7Uɭٻž̼օ ؄؅܉3qqptpvox,,2 $$0m, LP $TB `<88(PhHXH4l@800f\F,4,P$N +~}~}~~{}~}~}}}|}~~~}|}}}|}}|}}|~}}}|~|~|}|}}{||{|||}~~~~}~}~}|}}%΀ǀ܀輴䰌舐بЌఠĈАА̸ȘఠƐРИȅ*úǬŷ·rr¼؄ـvwy}|v}Ĝ\pt|px؀xpTn`xdpX`PX |Ȅp@P8008h@@@P@oH 8 `X8Hh֜xxhζŶľxwބ:usqusnuX,,\(  ,4 $00XTD d  ,X<0|<HPPHh $h 0 `H(HT<,4l6PD@, ~}}~} ~~~{~~~~}~}}|}{}~}}}||}|}}}}|}{|}|}|~~~}~~~~~}|}~~}}}+$߄܅@Ԅ Є)ԄQü̈́ ̅لVӀ̴ܸ̜؈ȸࠌ蜰̘舘ܔЈ"䠨ܴ̈Ȑ؀τ+÷ɵ@ w +hĽ{{̈́ Մڇۆ܀swwxz}ytwv4xxpxаxTDtx|PP\`|Hlh 00XPHP@PX`0HP@`X"0 @{plpxdx̬Ātx|x2$0 +z˽~؅ބcvyvwwvq`mm<$ P,H( $8DT4<PTD@<<00 (\ 8`@@P/0(@ 808(LnZjH < l4`z:0 Fd܀~}~}~} |}~}~~~~~}~ }}}~~|}~~}}}~?||}|{}}|||{}}{|}~}|}}}|}|||}}||}}~~~}}~~~}}~~~~}~}~}~} Ԁ1ք4ۈGι͆̀ ܲĄ،Иp蠠ศԌర؀༬Ѐ𺸀Ȩ膀İАаԀܦԂυ΂̈́4gg14.pƾþwy҅ ݄݉߈lurswwy{}ˆ}b\dtx`dtx̌ x8`ppp8@@ Px@@p0`HP#@Ph|v`X@ԞxpppؘЀ4\]j8¨Üwz|څۄPyvuvtttvʖ4^js>T< H888(L<4$Lh\<XH,(000Xp Hp@ 84,"(L4\T@< p (dF404ǀ ~}~}}|~||~~}}~}}|~}||~}}}~}|~|}~{}}z}~}}~|}||{|~~}}}||}{{{}{}||{|}}|}}|~~~|~~{}~}~~|~J+-*܄e!4 а䀈ȠЈЀļpИ輀谘ЌਐԀ 0mÄ*'(EɄ!τ3|yvwwwxxvwxvxv|zot|880 , <L8<~}~~~ }~~}~}}|{~}~}~}|{} |{z|}}|{}~}~-}~~~}~}|}}~}}~~~}}{{||~}~~}~{{}~~}{||{}}}|}}|}!~~}~~~}}~~|}~|~~}~~}}}~}~~ +C܅߆ ݄b1Հ̀Ĉ䰌ДРظа䀸܀Դz؀𐄸А؄Ԁ%ЀȤƊ踐̀,kÆC bʯˈӄ1}}wwvvvwyxwuww}xt||Lh|tpx@x|P`@ppx`pؼ؀Ĩ̸`xlxph0H|p8hhd Hp`P Pp@%0Xpxzv`x`rp 0䞌&R= Eγø-ф҄-{{ +st uuuvv$yxvuvuϽ{tp`}}~~a}}}~~~}~}}~}}~}~~}~|}~}{|}}|z|~~|}~}{{|~~~~~~}}~}|}~~}|~|}~}}~}}||}~}}~|~}|}|}|}}~|{}{|}{{||~~~~||~}|}}}|~}}~~{||~~  B߄6'܄I0'뀀卬ئ􌨰蘐脸̸،ȔȀ%਀̲쮠ࠠИ:l B8tǷü0քԄ&vv}}wwyvwxyx|@hhp`x̨Xppxp`pp |МЀ@@@舀̼ȼĐ@ @P dhPd|`XHlpP@x``P%h0ܜ0x0p `А`hpIQC>W˸Ľ0Ԅф&tt{{tsCyuvwxsp`DX@ PP$d`84$H$8P(P88 7d@4 @H @p\@488@8LD(HL@ TbpBڅׄ؂ׄ܅"y}vwyuvurhd|\pdȐ|t|ؠhx|hx蘸xhhpphPhpTX@tpP@xXPh`p(` Pޔzv` 0@|hx̌HPPNMQPQOCLѶ6هw{tpvytutnZPLh@<8 8p@ HTP0 0 4D((X@0 0XDhP @ 0P((0@0,\h|<%t@b^|2(""^t88(|x\PXt ~}}|~}~}}}~~~}}~|~~~}~}|}~}}~}{}~~}~}~}|~~~}|}}|{}}||}~~|}~}(~~~|~||{||{{|}|{~~}|~||{}~{~~}}~~~Sބ߆ޅ(֖؈ؼش؈؀ȀĤ਌``ȄДԠ𞀐*̚ܜȠ쌠Ȑgipfijhф̈́ȅK9V¼ýʻي݈ބ|vvxxyh$PHT(|ldPxx|0@ ؠРxЌ @h0P`@h`\b@\H@@p*p`@P~~xΈظԘ@܄@pԔ@Đ&MNWKNPNkʹօԅօلڋ{ut0xwx\4l\88,@ @\(04T,!$(4`HhdHlL 4<8PP84 h }VQOY4,Z¿üóƄ-ڄ6rst̋jL X L8<((,d(<!T0(@H0`Lh\$TT`8 (H@H   @"d66 ",(|df(T\\d8/@hX D \P L|(H`` XX\hT&8<(V$( + 08X( $~ }~~~}}||~}~~}}~~|~}||}{~}}~-}|}{}~~~}||}}}|}{}}|z|{|zz~~|~~|}}~||~}}y|{|}~ +}}~~}~~~||})||{}{|}}|{~{{|~~~}}}{~~}}||{}}~}}{~#쒘Ђ󀀀뀀6ވބمۄڅ&0شĀࠀ̴ȘиМЀȠ@`@@xĈ Ќ@аĠ8𰰨Ԥ؀Ā肘舸ࠨ>l\FHU.*Zmhpplouoklojqqijħˆ(džƈDŽɆʂ̅ʅ0܄߄߅݄ބ݄vwtt{zԧ}}v{w| \@DXx<(hX|pd`xtxtpȀЀИܘ؀@P@ 8lppX808(P`PXXzPl0txh0x༨ؠ> z8=ZRJNUUQQTQXYQO6!*¿ÄÄĆ/Մ܄لڅل܄ۃ܅݉3stontsvytwsur0 4πআ̶뜤謠ɰԔ0߄[-B +誈ȴȔаи@p𐀰а ȴ8ؐȠЀࠀܼĄΐذ؜>Hl`\`BDzrHl\xlmSkvͻsh<,ijkkĄ-ǭ_ń-ąĄHۄ +܄߄ބ +vwwzx\\vzxik0($p@0tD0td|dPTxh@|`pР耀𐀴Аذ@XXldhP8pp0@@(xP8p`0Ȫpྸtبʌp `蠰औ>PS86cML^F:YFX@TD8z ,\`(@HP~}}~|~~ ~~~~~}~|~~}~~}~}}~~~}~(}~|~~~}}}~}{}|}~}}||}}}|}z|}{z}~|}}}|}yy}~Q}|~}~{||}~~}}}~~}}}|~~}}}|}~||}}}|~}|}|~~|}|~~~~|}}~~}}~~}~|}{}~~~}~{z}z~~zذЌրʥ휏֜ ބޅ?  Ր𬸜ȼȀР؈x`x@P@px|@ܘ𐌨Иذ=ȰЀЀ蔚ܒȌЀ蠴`2p|xt|(dXXX{THDxlnYbp^xsfeF0mnʆ˄<  +6„"ŅDŽɄʄτͅ +Ї1݄ۄ܆ކ߀y}ƦdfZuxfpwʩ8088l|tP`x؀Ƞp`@ذДА%ؔpXp`\xlT`hHX`0P0 &@ؐ䌀ؼ|`|4؈xhh8i\cPP!PD4>K=23"@W ,4#„Ą†ɇ5لچۅ7vyzP&TY&:b`0T $ 0,@H$080P004<,@(@T@T DdP `$88\X84<$  dT(`. 8HL8 R$DZp,4}}~}~~ !~~~}~}~~}~}~|}~~}|~}~}| }z}}~}}{|~}|}~~~{zzy}~z|||}~|~~}}~~}~}|}~}|~}|}~}||}}|&~~}~~~~~~{~~~}~}~~}{}}}|{z}{z~|~S丠рȟ֊·ܐs('1ȔЊȚ䶐萼̰ШȄȠ谠x`p|`hp0xĐ耀GМȬඒĬܔ̘x@̀z`lXL0ZhXhphDmËlrfo\te~]6kjLDŽDŽ(ń Ȅ ˄$ք#ۄ܂݄v{{X~p^nxhvdiH$P40pzܐxlhdxt@x| ``@ؠШ؄АبȈ`@Gx̔tld``|xt8HX@@0P \t|pČԠ`zȰPX<*<<RVkP[&IL5$0+(.FVPC%V̈́-؄ۄ݄5swuσ@0,aZ 0 JrJ_$,XRV0\ DD`< $siJEh.+OB." &8FTQU>4¾Ƅ"х ׅքޅ߄(vrsmw Zn(rx8 ( +4D0@( 4P8X +$@88X@`@<P00FL0HH8 $4T@`ôf2@$44DfJv@H`h(T}}~~~}~~~~~~~}~~}}}~~}}}~~}~}~}~}~~~}~1|}}|~~}~|}~}|{|z|~}}|{}}{z}~}~}}~}}~||{|{{}}}|{{~}}}}{}~~}||}}~}}~<|}}~~}~~~}{}}}|}}||~~~~~}~~~}~|}~}}}~}}~zz~~~z|{}}{~HÞЌʦր"2ȴԌ𬬈Čܜʌ@РؐР bx|xx|@t蜌謬Ȁؠ▬ȔȐشЀ@H|pXDXHld``H8rj~``pZLmfH$8h0H,XT0 0~}~ }~~~}~~ ~~~$~~~}~~~~~~}}|}|~~}~~|}}}|}~}~~}~}}~~}}|{}~}|}|z}}|{}+~~|~}}||{{z}}~}|}}||}~~}~~~|~~~{~|}}}~~}|}~}~~~}~~}{{}~}||~}|~ }}~||}}|}}~|~ {|}|{|{{|z}<Р茸Ĥܣ( K ̔젨Јܬ؀ȐȨȠȀ@x`@xxpP`|ȴI𐀘ؠ踨ܠȰؚ̢جpĊʜ𨸀Ȱ@\\XPd|`H$dPPh4TJhsү,\lD^lru|trmkn˅-üKlʾÄćĄ͈х +ӄ݄܄vz}Xpdp08HDH8@0h| X P`tXtdP||``𸠰ؠмЌȴ𘐈h|@pl =plhxx|pp`ph~ЬؠPpXTPDP`DTy,,84EwPAGF8*GQJO^\GĆ̈́քׅۇ%tvtu$L(4`4THX< LDD@$ (p@(P8(8008( @0@XdDFH>@,@8<$ (@(L@ >6.N @D~P ,lPP(}~~~ ~~~}|}~~}}|}~~}~}}|}|||}~~~|~}~|}|~}|||}}|}~}~z}{|z|}{{~}}{|}}||}~ }{{}~~~~}|~})~}}~~}}~~}}~}}}|~}|{~~||}}}~}~~}~~~}~~|~}~|~}yz{||}|}cĔجĘИԔШ肀(!༠ذ਀𘌬܈x𘘀蔰@hhP`ȘP@x `ȼЀЄ̘Ș2舘ȜО`Р@Р@pT`XXHXH4@0LT8H:  ЌȞаРЬȘ踸Ј@p@@|ȈX`@ЬܐЀР踰Ѐؠ،ȰШЬ씤иȐrlpиxxxXXPxp0L\8nnmolpokmnqfjllfdʸǹıƽRjij>ƅ +ńʄ͆΃хЂшԄބ x}|{Ĉf~pzhtP`@h h8p@،ԠpP|0hXРа|ЀаШАXܼؤ茈Рtt`plxxPhȀЈ@@@PP @،ȼȘ0vn@ ԄА#ldhxxp@0x`@8P( 8P<TZtAURJEHHFFHNEHMOFzDxzwsQZ>!„ȇdž̄ӄ؄ڄ:u{ws~&8 HLTt‚g˽ƠýƲ÷j†Ņ +„†Ƅ̆͂ΊІцք؄ ܄ vwwxxxwxywywyzzz~zvP~~h|̰@P`rP p\@Ȁ@P@pHpx XX`Ԅpt𠨈Ȝ萐Pz@p@HrhX@|` xPp`̀txp@ld``\T\8xp`P8@XPXX@HP0H8$@P$79HPIIV9bDD LXHH$#\(04x,( <DH:D }sTX8<8TX`J#\DD,,FJXvP <8,P bLl, 4pxl4`HP4 4<8X H( <,T:$P8ddD @@8N8XZ$P8Xd8pPxPP `~}~}|~}}~}~}}~~~}~Ҁ +~~~*|~~~|~}}~|}}~~}}|~~~}~~}~|}}||~#|~~}{{~~||}||}~|~}}}{|}|{||}~zz~}z}~}~~~zzz}~}~~F}~||~}}~}||{}}|}}~~}~~~}{{|}}||~}|}}~}{}}}{~|z}}}~~{{{}}y~}}{y||y}ļĸ踸Ȱ踈ЀڤЄʨ蔄Ԁ(C#׬ۀ˟̸쀄ܾԞܤؠ܂蜤ĔԔ萤฀`xlp`@`؀Ј~ذаМЀؠа؀p`xxppldT\x``\h|Px8H0,H4fw|PLVt]~(ldJ\ \8@, " J8,ZL\Dp<@h,0 X( <4 84`X<@000Hp0P XH88H`D4 2T0D<(/l<@` , 0(c&b X< DL(D|0PT,.:4xB<\|Td,4 $(@(0@T Pp@h`@(H0DhT$@P< (\@ @X<4$`<^|\zP\l`h`Pl`xd\(`(PP8ih˺ùi̩ΧoruX:>B:P0*RBP(T<<VX<,\48$4(|t< HH8D8HX\ h<8 (00LQ$ 4Th$P\$(T~~~}}}~~}}}~}~~|~}{{|{{}|}z~~~}}}|{~|}|{||{|{zz}||{~~}{|~}ȼȸȸиȨ䤜،舸дȐРઝĀ݀蔤ؠVȌ񄀀Ș蚈옐Ȁ萨Ѐ@xxx@xиhJ̔ج蘴Р@P `İ~vlr̬|tptphhl`pp``xx@LhH`PH^LDx\X0HHhXP`lxp\8PPXhlP\`x@PPhL f0xajkʿnVjlTd,Th%t`hĐTo¯RJdHȳC¿Ȅʄ˄ τЇ҆҇ӄՅօՂօ0zx||z~zwt}n0ZLX,P|8 (l\t<l 4T 84Lt8HX`(`@@0"$$@T 0t, 8d8 H(, dX0P"\Hp|h@p xP4~~}~~}}~}~}~~}~}~}~~}~~}~~ }~~}~~}~~~~ ~ +}~~~}~~)~~~~}~~|}~}~}||~~{}~~}}~~~}}~~}|{~}~}~} +|~}}~}|z}~} z{}~~}||~}|{}~|~~|}~}}~{~~}~}}~}~}~~{{||z||z~}~||{}}z|}||{|}{|~{}z~~}~~}~ĸаĨ؜Ƞ蠔Ԑ𠠈Ԩ؈؀̬𜔬В $ި򄎨Т잆Ȝ̈ЀĠ䴀ȬȈȸЈ𰤌؈@ذ@` ̈UܰԜؐؼ@Ș؀@PppР`ܸptxnp||xllph`}xxx`tL l@dX\p`Hl`Ph`|h\PPtp``PH@PPh PT 8 a~~Iz}wx{~   „+̈́ӄׄ؄ل؂څnlrssnstsqotrqsq|,:2<&K8(B,((b0"$:& H0>H4H((8>X\N4(TLTH0, PD,HD08*@H8 (L8446@, ,ltlTP(  (< `H, L PP00`<0h~}||~~}~}~}~}~}|}|}~|~||~~}~~~}}~~}~}~~}~}|~}~~}}~~}}|}~~~~}~~~~|}~}~}|}{}}~~}~~~}~)}~~~}|}}}~}{~}}~~}~}}}~}|~~~}}}|{~}}}|z|} z|{}~|~~~~~}}~|~~|}~|~}~}}}~| +~}}~~}~~~}{(z}y}z~~}~}~}zz{y|~z}~{{}z|zz|}|}}~}~}}~}иȘЈȰ蠄АаȈА쀅̐DȄج +$ 쏾΀ŀƄȈд䴐ࠤ쬰аؐذȬȠ𐠠x|pؐм𨸴ؤİ줄LȀ𐀐𠐀舀بАЀ@@ఀP||~vjlȰtlpddlh``xhppxxppl0hh\d`hXT|p`\\8lDH`t@HPhhh@pPHP\HHpl8L(l0ngPxX\0xD XXRPԆ(xL,~Ǵ-Ʉ˄ф"؇܇܄ xy}Ѓb~wyzwvxtT8@Hp0Ęv̸plzr|dzP lXhpऄܞ|Ȁ ШXtl0x@|ШȠ蠰g蘠и`xx Pt|hHx```@@@@`PАʈഌkvxtfhxxth`dXX`\pTTx`PXX`ph`@X`L Xd(\@H@0  0d@4 ,H8  X@0@8$4D$YQ5(  4< $,@D, ($,@,xd@T@@h8~~~ +}}~~~}}}~~}%{}~|}~}~}~~~}}~~}}}~~}}~~}}}~~}}~|}}|}|}|}~}~}~}~~~}~~}~~~~}~ +}~}}~}}~}~ ~~~}|~}~}~~~~}~}|}||~}-~~}~~}}{}}~~}~~}~~|}~}}{~}~~{|~}|{}}~|~z}{|} +~}}}~}~~}}~}~3}{|~~~~}{}~}~}|}|~~}~|}~}~}~~~}|||{|y}yz{|}~~{|}{yz~~{|{|y~}}||}~{~~|иȨ𘨘،ȰذРЈwĤ̠À$ ़ܴـ䐄ĘȤ؞萌萠Ĵର਀Ȁ蠐аpȀ䰘ԴNȜД𔴐܄ࠀ薸|ؐ`rjztмlplhhhdxp𘠀hhppLTpplpPxphhpxP``\h88PP8Xxx@h8pPLTHP(4$Tnj0xXh`x\l8ȧyTRκȲŹľʆ˂̆քq܆ څ+uvuy{}҈0vyzwwxv||}nPThpzdxlXd |d0dȈ@Xt@` p xܨ|@t|xРpjЈЌ̐켤ܼ@@HP`hh|Pttpphhpxx莨`@@0@ఠkz|lbvpȴ|xt`d`\\\Xppxx`Xhp0(`tppH0 $8hpP0DP(,,PH0P@H@Xp  8P ^D(<HHHR \8n<jffe{wvrm{w~z}}~}xx|x~yzR^ń*ijiЅՄ3mproNj040Z0-\\(ll@08<,80Lh0 0(  4(, @(48D8@,L @$<$<,!4D\0  @<8(hxXlXD`8tPP~}|} +~~}}~}}~~~}~}}}~}~}}~}}~~|~~~}~~~}}~}~~ +}~~}΀ ~}~~~~/}~~}~~~~}~~}}}~~~~}~|}|~}}|{|}~~~}{~~{~{|{~2}~}~{}~}}{~}~}|}~}~}z}~z}|{z}{}}~{}}~|~z}~~}~~|~~~}{}}|~}||}}~}}~~}~~|~}~~~}~{}{}%z~||~~|||z{yz|z}{|{{{yz~|}|{}~|z|z|ξȸȰࠠศаԐऴK萌̨М ؘӪļ𰘼𾤼䬎؈РЀظȌААؼਈ@`ȠȸАذN̤̄Ȅ褀न𬐀耠Ġpxp@~|tl̼|ttlh\``xhPHPPxXPhhdpp``h\d`PH`PPxh@x`hhPPP8(\@TX@||ȠXD`T`v`xhȠ`XKllhtdPd8҄Pt pɵûa¬að˿ņ$͈τ,tqpq݈ن܄߄wz~p~pttjj0{z}uzv}vdxdt@~|tx|llzt@|xlPxxȜx@PP PpдxЈhȴ؈ਈ`иаܔܐȠttx t Ā@xh|plhX 0pp` P``P@ 0~xxphĴpxtlhd\TTTxxppxxh(:(`l\xH@0(PhhH(p80,4<@0@(00  LLh8 dN Dh<2@ .T:4Qxx{znoizw|~x{xxtExsFxw&<`]]^"̄Ӆ]lmqpx>L422anqinjp@.>0>J(>2,\J`2<,( @X d,< 8$@h@TXHX0( @0@(A |8<4H04P0P< (H4T<,8RX0hXx@lhnX@@l`vlh`~}}~~}~}~}~~~}}||}|}}~}~~}~~}}~~}~~~~~}~~}|~~}}|}|~~}~}~~}ۀ}~~} ~~}~~{}~~~~~~~~~}~|~ |}}z}{}~}}}~ {~|}}{|~}~}}|}~~}~|}|}}}~}~} z{||z{z}~} |z|||}}zz~~{~F}~~{}}~~|~~}|~}}~}~|~~~}}||}}}y{y}}~~|{{z||z|{|}|{yzz~|z}}z~|~|}~%ƾĬ踸ȰȸШఘШ෎Дؐ谬Ƞ،褀 +  (䞴ФDŽæ̂؆ؠЌ자ܠࠠ𼨐茰ȐȨȌ؄~6؄Рؘ舠ฐh ؈ؠ~zvpjxtlh`\xxpp\PPThhhxxhXP`p`X`x``x\xX`x@Xpxp`@XxP`pd@8dDtdxxtPԠTL`XpxPp`\0ԘægµhĶĻfŵ +ȉ·φ ׄلoupq܇݄t +utttt{|~Մ`̘Ȥl~v{}htff~@܂8|XxL|~|xxPȠh@pȨhh|АȈĬА𔆴Ѐph|PXlp`pp@pp `@P@ `%~xxtnhbtxph`\TPHTTTexphh`8H $PT}{| +,YZ\h^\^\\  dde +ʄ +h3jjkjimlnk(<.6o{&0DP $6 $ :0H8^@$,(TH< <(8,T0`8dP ($d,$04,M 6P$.\FXnty} (eV?\[\Z dc¿cc̄΀ggghhlnimgmn*r88P\`jhmXF4LD,V$F:<6"(<p$<,0<CR,8@l(H@H8P`000(`8(p8(H8PX`@08008@H \HX~}|}~}}}~~}~}|~}}}|}}~}~}}}~~}|~}{}}~}~}}~}{} +{~}}}~~ + ~~~~~}~~~~~ ~~~~ ~|}~~~}~ }~~}~}~~~}~}~}~}~}~}}}~~}~~}~~~}~~~}~}~} ~}}~}}~}}~}~}|}|~~}~~~}}~~}}}~}~}}|~}}}|}|}||~}~}}~}~~||~ }}~~}}}|}}||{|}|} |z}}}~~{{}|{~BļȘبȨبڑ؀̖Ђ$ Bઞܨ肒޲ذԸАĜȈԤ̜ܔภ耼ܬ𰌈ȄȈh|న@ȈԊ ਠఐЀȈР؈ȈؠB¸xnljjhd|llt|thpl`X`ll`ll|dhdTLTxpppxphP08H88@H0X`8phx|8hThl hpHxXxxp0d0lpx`pxXPdP@ؠ@x@h8`Xl~Tkʸjm(484 lHlj`h|xT8Vo$̈́҄Kwwhtnwr zx(vxz{~|xwvvwy{}w|}XpĐr~lt|@pdpxԔXĠX@X@hr~Єdxv`dhxtؘh|xtĔؠ||h؀|pиذȀ𸸨̨А܌Ԁ謸`P xlppȀ~|舰pdh`Hh`xذpP࠰h @ЬRz||rpnf^^\\ZV|xh\LLTd\\hhXPPTL@HPHXXHLPT(4 D N~Pwwuut||zy}C~DCEDDFFKMTY`g 82kcZYY[[]c`]^`dd`Bùcbgćmjjjigfci}WH Hnn@ 4,"2(@0$  @.8 fJb< ,D0p4(4B04L H H@$hP4Ll D D,(@(PHh@(PhP<8(8 PP(H^ >48, JH@b,TP^LX0pt 0T@ ,0((@D`$ <<0HH(P@00XL@8@T@p@@( 0X`LhHL@0X$\(pl(PT@ 8, (HDHHLt\(( XH 8@0 (0hlLZb<6PRXjL~~~}~}~}~}~} |}~}|}~}}}|}||}{|||}~}|}}~}~}~~}~}}}|}~|~} ~~~}~~~}}~}~ + ~|}|~~}|~~}~~}~}~~}~}~~|~|~~~}~~}}}~~~~}~}~}}~}}~}}~}~} +~~}}}|}~||}~|}~}{||}~{|||}~}{{}}z~~~}}~~~}~~}|}~|~}}}~}~}z||}~~}~~|}}||}~ĚƮԴڲԬҰưĸȸب𨐰r舐Р𘨐؀ҤЈ؈بȐМ耓АİϺ蔼Ȩº‍Ԁ̼А̘Ԝȴܨİ̴аؠਸܬरศ谸|Ĉ̐АƸȸ¬Ҿހ҅IJzrrnjlrjdbxppld\X\Xxp`hXx@pdXPPxp@hh`lh`dhhHp`phh0x@@p`Hh@`hPHp@pp`X`pPc<\hhHxx`XHxH|hHTlTd`XXN(nkTXDf `q÷fimrqrsBtuw߀|phth½vwsvxwy~~wٖH08`lzvuwtyvurruutuxtxVTdp|0\|zx|x~|xqmPTP\tt@d^|Єhp|̸px`PtxplzиȔxllм؜pp|аȸМؠм耀|pАذP؄ഔरศxЄؠp Ȍ``h\h0 tt†~yyknƨtph^ZZN\^t\PLH@84D@xhh`HHHXXHPX`P@0PXxT@X@0T0( P@bJ\pvrl*@,.2>.TT6LD~~~} |}~~~}}}|~~}~}~}|}~}|} +||}|}}{|||}~~}~}}~}~~~~~~~~}}~~|~}~~}~~~}}~~~}~~}~~~}|~,}|~~~~~}}~~}~~}~~~|~~~}|~~~~}|{~~}~}~}~}~}~}~~}}|}~} ~~~}}}||~}~||}~~|}}||~~}~|}|~}|~}}~~}|~~}}|{}||z}~ }~~~|{~~~~{|} ~~}}|}~~/ѹڪy̰ξ¾􀄈ļШ؀ȠȀҨЪЬ𐘀਀BƐ4ȸீܘԲ܊ 򄀀МȌ̠Ą쀀ꁀȸؠаذĸИܢਘܜ𨌈ĨȔศиАȤ𤀠Аĸ`脬耀ȀࠔШl؂Ԩਘа¤ ȼ؄怯϶Đe|zrlv~rffl`^^``^ZX\\XR|tph`|th``\TT\`xx0x|pHtLhhX\TTdh`X\ldp`pXpv@XPPp``@0X0P@f@xРhP8X`8H`PP`p0xhH0hz~uqdrlh`kglggm҄nnnoooppqrrssrJqsssywu~|lxt`0zzvsڡzdx4qh~rttvuuwvstwtty{HhpĠttp`xHTxx|ylRXp`x\phptX0 |x`vX~ppxttpPĖܨP̌Ā̰tplxxt|Ќxذ踜xp`hphhpXlXȈظpРДxz̀p@0d¨nʚоƮnvǦԁ¾´thjnl`X\r|pЬ\`bd^pd\TLDzttAz~x1EGGGHIIKKLNQRRRPONRXf 882>gZcaWNPU5A2:$TZ\[XVWWWWWVUUQSRX_ ,k`,( X^^`bR(,D(8 $0<0J4q$D0H@ F@N0`\@(@,0PD 4<4$,88@ld@$404XTP@  Hh`@ D( \HH`| H ( P@(S@<4X(@X8(8,hT (p8p 0Hx|(D`(` @`XnFLZX,x*HV0>=>,.R6<:rPB"J(~}}}~}}~}~~}~~}~} +||}}|}}|}|}||{}|}||{}~}|~~~}|{}|}|}}~~}|~}~~}~~~~~~~}}}~~}~~~}~~~~~}~~}~~}~~}}~}}~~}~}~~~~}}|}|}~~~}~}{~}|{}}}~~~}}}|}~}}~}~}~}~}}~~~}{}~}}}|}~}}|}|}|}~}|}|}|~}}~~}}~~~}~}}|}}}~~~}~~}~}~}~ }}~~~}~~}~~~ʣ욯עЃ|ЌhlĴƮ̼ȨZиȘАܘЀЈȰԠƨ40蠨ȴİ예ԇҚ +䉔ܬʠȤФ耰𰘔耀ؘĨ옄ФȐะਈИ𘰐аv@Ƞ؈ЄؐА谴аĄؑ첊ִȣϙwbpYZtltpx|||~ztvpjdbb```d^||xpltpldl`XHXL\XPllx`TP`tpXhTHX\X\`hdxxXXPpH{8XXHhpHP` pxXHph0Dr`X`p8 P Hhxhpp``h`xvytȨhhqlikljՄnnsBvu{ĄxP аhsywp{khf^h`t{ptuvwvxtuuttuutvz؀zd|ظhȐnrPT}zz}ӳrpThȜx`t|n|P PP@p܌zvP p؄Hdl```|ਔԀl|dtpȌਰȨxxpаxpppxȸ@xpxtXT̐pnܲz|itpƾxe]pkś’܏Ҽƺθp^\TNLPTZXTRL|pthXLD<6L@Hd0FN^b[*4\;POHC@ FDXaUVTTVTVRSSRDNOOMPUcUQ4`T 02`B}e_\]X_a`l <0H$`\: (vd86^X8lFT(x$$T8ZhTHDt<(t 8$,04(<$,8(@H@<8P@PH8 HX<@888 8(@(0hp8 0H,P$DT$<L\(0pn^@~hۤ{vruwuvutttut聸p|Ȉh̝zu~ux^fxLtP`|x`p@hpؤԐtx@ذP dhrȨ|xphllxpxظpppА`иԼࠠpȰ`HxHhdpxlxʼĮjfzqlƌnhǧʵecʮֶ􀄂ĸ|hZTNRX`fh`ZXRF@@Lr@:H`@0Jn"FdT\\r4:06~}~}~~}}~~}~~~}}~~~}~}|}|}|}|}}|}|~~~}~~||}|}{{|}||}|}|}|}}~}|{}~}~}~~ ~~} |{|}~} ~~ ~!}}~~|~}}~}~~~~~~}~}~ }}||}|}}~}~~{~}~|{~~}|}{}}~}~}}}~}~~}}~}~}~~~}~} ||}}|~}|}}|}|}|}||}}|~}~~|}}}~}~~~|||}~~}~~}~~}~~}}~}~~~}}}~~~}~~~wٳ~ШԎhfVaflib¸ȴиаАبȸи𒄀Ѐ𸐈И옘ļ쐙րQ耀ϲ䀀۪ʸԕƹ쨌̐రĀڨԤ褌ظ𰌰踴ԄмЀ萔ȠܼЈԨ̌𐰈茰ȸଐȰpМpxࠀ؀м蜘 ºʰȐ؀،ༀm|۶uvW~VHpSzVZWP~tlt~hvt^`hl\dbX\\tpxxhhlhd\\dptdlx`pl\`xpp`p``ppPxPp`PpxX``zLHHpHXPp0ph`8`PXppx``hXHpPm}LRxh`TXf` z\cxtqmpefegklpkjlmlXmonpssssruwzvvx{`TPHpxxxppؤ|`Twʸ\zf|@4lNhytxvvuwv{Ұlؘ|phLsxz{ppdļx`pȬ`HtpЬؐܠt@`@謴Xhx|zࠌx̬Ѐxpx|ЀtЬȴШp؄hxА`xx฀蘈Аؘ`t`\0l00~s̊hƺ zlXh|lаsƎť妎x捆腊znr^Z\bhhhzrR`ZD@HH6><2h::h\X\\`\\TT@>t?p>yB~}DDGIFJLMONQOQ[_284@0jb[~TTRBs@j[QSPPJTU^l`$0P$4N2af~9s~F FxOj@*P<P4TP$DH`XXXD hp@40H8<R4hH 4,<  (@@(X0,X0<(`@pHXP`PPxP(@@h hp ` 0pp @XP0P(@8(Tt4D0P\000P,8 <Fb H<L`,XHXhtH(Hxl40@`j8L8H($@X4<4$T Xl44D8HtP(`@ H@00H`P@0XHHH00x800HpX0pp8dppHp \`PHhD( ( 44Dh\V\|np`IOFPf:bXXrzE<2,8bD$8PThD 0`~}~}|}}}|}|}}{{}|}|}|{|}|}|}}|}~}~~~~~~~~ |~~}~~}}}~~~~~ +~|~|~~}}}~ }~}~~ ~~~}~}~~|~~}}|}|~~|||~~}~{|}|}||}~ |{}~~}}}~}~}~}~}~~~}~}~~~}}}~}|}~}|}|{|}}|}~}}~||~~~~|}}}~~}}~}}}~~~}}~~~~}~~~ze|܀宧d̸²ȼȀаȰȠАȨмԼмЀȀ>̨̬컙иĈۀԀ 쎜蠎Ȱ̘ĨԠԘذ𸀊ȒxИ으ȰؘМĬȨЀ萘蘠xȸȼxȀƬ샎Ȋ Ⱦ޶ܰФЄp2lܤĀvʸ˙vxܒؔ~ztp~jvtdbdLjT\bdVNJJJD@<:>@B@><>phXX`X\\X\TLHdHXT4PtTXXP@8<408@rOCA??p>:_:8lt?{{A~|EADGHDIJJKJMNPOH 0@(P < LfCdlJ:$yMMNIIHII LY T, dZ#I>*z(D 08XXP 8JH\P08o\0@8tfPH ((L48(l +8 $$L0$i2( +@B|EHr1m}s=>=:96:v@>x>@@BDCAGEEGJK^, $HlH8Y I `PYTOHHHJIIMN? +<\\ 4$ KDaw|) 4<, , 4@HR8XPLX8q(` t,, L(HPT\` d|dD8J^HR,T |8 ($8l 8@xXH0@ 0ppP@ |Dl<,@@@`PhX@\00d4B<$LA0$Lhlt^v~:xSlZ`xLQZ2vO>r`lH0F4FD@hhĠȘz~}|} ~}}}|}}|}|} ||}}||}|{|}}|} ||~~}}~}~~~~1}~}~}{~~}~|{{}}~~~}}~~~~~~~~~~} ~~}~~}|~~}~}|~~~}~~~}&~}~~}}~}~~~~}}~~}~~~}~~{~}{}}}~|~~~~|}}|~}||}{z}}|}~}~|}}~|~~}~ }~}~~~~~~} ~}~~}}|}}}|}~~}}|}}|}|}}~|}~}}|~}}}~}}~~~|}}~~~~~~}}~~~~~~~~|˯̿suRTrzb̩ژ־񭫽߸Ұ¨Ʈ¾ȌذȸАЀࠠ踀􊕺̴̒Ȱب،𨜄ఈ +䘄ȨԘĴ萈ĀԈؐИЬЀ倀ԂشȬиԠ蠀ਤЊ蔌켸茰ؘЈؐ،bШ세؀􄔬Ș싂ڈև؇螂Ĭ||x`fdrdJ~VPBRRpW\WxW[waA|˲̾o~rLPlnnL~Ԙؾ򭬼⸒|htt|ztx|nzv~zvx~~vjlljhfhff^|dd``\PTPLT\xd\X\xphhxhhhhP`p0XPP``@p``@H800pxP``@`xp`( 8b~jlj|xh`pdL\xh\H\TpdƐofvilnjookmnnsrpturtuuyx~rldxЄpHl\x`Dh`hDLe\xstrrppppqqqprrqp|```Ȉxp|pΪyz|yclVtxhxl8hpT\xĐܐ@x谠@ptpܰxhh|`xиxp~ȦĠxhАpxp|иЀЈ̤xȈЀȼШp`PjrxouҶwsڔ֖ΐv~b~xv|pfrp~rVchXb`~`hdfgiIpӻע߾ԶƸک{xμlei`ĪllXJ:>XXvlpx~tbTRRVX\XVTb\XV\\RBBB@>@@>>:l||lhpl\2TPLT<@<@<:?pw@=?@s|@~ED~DHHFEFHOR\3(0h<8(HX D80&l,]d8b>H4MLKKGFFHIIHFGHGG`.8(DlT`_^YV% <@ @0DT0`0a80l40(80DP,,h(0J`r`8@ DLH(0`h8(@P`(8H X@80X(h@X(4xT8(H0(dOX$.|rMDf`JvTD?jzf|vhZ:<@@<:>tdX`XXXTTPLDDHD,84PBnKpjۗTԆֆ،𠌤޴بР؀谘Ш࠘윜Ф𠬤丠ʀXȀ蘰؀@ؤ܌ƀ ꎜи̤ɀ޼мȨИРԼȖ”и|ЌذؤĜظؐЀȐ蠬؀ؼؐŽҮҀ̎𐘰̄Ȁ|fa]d\PF,6?KLEUW\P5GM_ZeO7FLRdOs|@BmJlg֏~TЀjrlȎvh~jtz~~||zttzpjrvnjlldxxh`d`\\\d\\HP\x``xh`X`0P`P`P`0HXPpXxx@hThHtd`\hdxpx`\Hdpdx\N~Ӕ_lXlmkkomrrwtosvwuH|萨 P pp||Ȭt xx`\¬sru݆o ppoۄ܀p΄|`t|xp`{~,TL 8Шh`P|ȰؐXppи|pxtȀxРpht|l`xhhpp`Ѐxx||ظ|tptrȴdujĪzoʶmjаtxĄȂzhlisk`W?JR]\S_bcX?T\rmxaGU[ˍֱВ}ulwh~d`hP|n̘hfdXfddjlprrrtvlnrth^XTRTRPRPRLRVLFLPHDJJDxx|xpxtld'LLD<44<8tpCC{{KHDC{CIL\e ((pHh x4(T00(HN:P`sRlMNHHG6FFFHXN  L\, v{YZ|@4840<$((%<v PR P8(HT8H` ( dDtX,@ @X(h(8\0p hHP00HPPL0 (08@4HH(@P(@8L ,P(V2d.D8nTxv`z*>NMm@BRq?XCF>jELMKNvMMGDEGD{$(0d@p@ @ 4 $j^XUJIIDDGxP D84$(RXQ~5rOX`V$L,4D4(( $4X,@<8D4PH<TxN0PH(4,J`,`Lp,DjDLDlTD , D880$8TP@HHh@(Xp<88@,4<@,],H4B4>3,$'IywMz†XRNPJbbr^VJL>DPL^rZjN>:<@>xxhddhhXHD<<4048DPPH@yxyyz||~wDp=s:qwy}}{"\<"8((88L,2[ZH67X^D@L44, <480H,H4<@S \D4pT(xD@|8D6X@@,$4<04H48$D <@ , 0(8,8PH(P(`<480(LD @e 0(L^pvF`HJN:&z>.4H0:VFR^t>hxCSzdxd԰ȒĀpغƎҜ߻ؽ~~~}}~~}|}~}~}~}~}~~}~~~~}~}~~}}||{} |{z|y{|}z}{~}~}~}~~}~~~}~~~{}}~}}~|~~~}} ~}~~}~ }~~~}~~}~}|~}~|~~|~~}}~}~~~|}~}~}{}{}~}~}~}~~|~~~}~~}~~}~}~}|~}~}}|~~}~}{}~ ~~~ABK420?ItTXaulms{q}SZȮ~}؀٫֏Ȣ츲ʾҺƺȸиनܸؐظȀʌԀꈗɆ֥֨ఏؔܨ蜀ЈpਸبΒ𬀸䐣ބ贰ذЖؼ̒ʀԌ踨𸨐萤ԄȀЈЈ𖜰Լܐ̌؀ȨȸȘ؀Ѐ褐Ш𜖎ĸ̚ľ̰Ďp`^pXZdfVxnnpT>tTTjPBX]^vgiȊaK?fbk^oKUNMaxaXmIprrXXaI*1EBQ[[^g{povրyœZ^Ȱ~lzߏ؀}צĄrjvx~xn|rllzzlt^rzx||vzvj~|xtrnljfb`dĴtxl``hh`hpl\X`\pp`pp``00X`H8XPP`XTXxtxthp|ttptpt|tlhlxT`x\8h`x\`T|`pjNΩU^Vg\VLthb@PBh_̵(rȐzppؔp|px@xppȔl``Ȁph thh<tprokmnmnnovZ\lؤЀРtuݐtTyx||htX(H|xxxЄvlttШЄxp؊xtФ`dvlpdthx|аt࠘X`vmtƺzָĸrĦpl^`hrbjJrpx\Qhlo̜ΪstԖjXLupym_kcasndwO|ƭƾ񅀀 ٻlxctrHhpmŗȠƈ`xBJl|fܶ¬jzbDhjp\\`PvVdNHb^b`RrbPHLRVbj\^RNFHJLNJD<:ld`\hd`\XyTTTHHD8FJB<46:<>HFxpd`TPPLDDD<^$5T$fdz袾ˆȜ|ԘڦȠ܎~ܚʰҾ²̴̴𜄔ȀДАȈ꺀섬؄Ȅ´ਨИئ쒘Ир𔈀轒ȀشДȐp~аشȌhxȔܤĘȚȘన谄ర؀ȂΒʼڴҘԊ̘prdI6!;FPI^R?jpNX`?8cmTI>.Nmp\T?\dVIb\bx~WU?KmjbhM6SZRT:_ONNXzgKdtnv}{ulCiWqO]QCQ9}oMH[7 *%2w?PNn~梽}ԽdvąҗҤÛĀrȐ세lvxnthlnrbvlz~~~|vvz|zzzrrrnnjfd`b`|t|tdhpxhhpppl|l|xlp`|hlxxx`ptlh``hlhThd`Pp`xp|Xl`vnh8|pTRHtRZz\@PpntLrF@DV>HF@Ldxpx||؀𨨠 pptl`ppph\hp8DRbPtsllkqnoo¸pxxxpxļ8|~pP`plx|xt̤d̈tp`hlpت|䈘ȠȨȸȐl|plxdP``lXxtxnht|}vʈhzjnfzX|ltlVH7T_eTeZH~frxLCozcWK:Z{nhRqxiYrkokiT_|uzaJhrkpUxfddqaxyʀ݀ѳШюʑroxlPcnn~ʦ~~؊~jdLJTT^XHVH`R`JX^`HVvdtdNNJ@Pz`hd^TNHFD>@DJHFJTVTRLD@:624\\d\\tP8<<

D|,~fH|LT^$",2x\ Xtܺ؃ȶиŸ̱ƹ·~~ ~~~~~~~}~}~}~}~}~|}~}~}}~}~}~~~~~~~~~~~~~}~~~}~}~}}|}|}}|{{}}~}~~~}}|}}}~}~}}~ ~}~}~}}}|~~~~}~}~}~~~}|||}~~ }~~}{}~~}~~|}~}~~~} +}}~}|}~~}~}}~}}||}~~~~}~~~tu|rST&0,76$#'%8, =%!.,CJhxĺ̀ĉr݋r蔢ޢڅ꬚잌ઘ$¼ļȬȈȜࠠ訌ธܬܸ蘜Ԅش،蜤@𘌠аؐЀİ؄ะȘШl蔄İȞЈȔؤ਄䬰脈̴躊֠ΚȲĄ欸Қ̸̜̈ܤČj<|^bgKfXI[^IQHizykdPG2G=85IfP\mmQRxNuYfeotw{Hpj[NJibw@@yVB?:^jdnfEBhyRqt|U_{|ce8A?OSEFLG>PTPNJHDBFFB@BHHDJLBhPHHH\TTHDD<@DLTXXTHDH8DP8d\`X48$@,( @H (00  0P4@L<8`t>2HP;0,p(L@H88T8((4H>QBqn>+*QuSLFjԄvtYd.2pL FĪд«h~vzv|jtlbzxp|xnprt|ntx~~|z|xvzvpjnpljjhdb\xlxht`ptXhp\l`\xh`phh`0xxXx`plpxhdPHpXTxl|h`rpZifj\|l`rxtȠpఈx@ȸhhtdhxdlTX\vpbXvnio԰nnkloqvr```pl`xzx||||ШԘؤ@аȨpxȨx؄𰐔Ȍ|PHlrtrzzؚ̪dԖĴ~δĀyrz輤l~ftZ~@`Zj>Djjt~\fܤ؞TdȎʑȽپѷ׮̬ +~~ ~}~} +~~~~~~~}~}|}|}~}~}~}~}}~~}~~~~~~~~~}~}}|}}}|}|~|z}~|}~}~}~ }~~~~}~~}~{|}|}~}~~~}~~}~~}}~|}}~}}~}}}~}~}|{~}~~} ~}~~}|}~|~ |}~}~}~~~}~}~}~~~~~~~~~Дͽk?:X+CB0;/*14D1yp~8t\XDZtƏesSj]oNz—||z觼`tr|Љؤ욊̈~̼ؼذȠȀАȸؘؘଖܸЀЀРऄ蜠Ȝ̎ήවഀ𜼸І̴฼ȼȜ記Ј|ЬԸxtxДЄ҂栬ؤ̮沦|bbP@~vNb^BFp>.`ff\ltXNOPThBffxxtYJ`JV8p_nRn>vMab^oXNGCJVTvc4ekhszi|mwu~\͓wPOoDc?<\H5OC?D.8IXDsjHxhhhrxÊfx_tgxTƜʋ리{ؠ|v~̈́xlĂĢ~pn`tldtxp~zvrrltprz|rz|xvvvtlhdb```dbtl`tXhd\\`\hXhXx``P`lHp8t\XxhTxȔxh|bֈzrlhrx^Ԉظlxpx||tȘldtp\flІLeornmnv`>xTxnlootʔnxxtЌhp\dfФpph؀Ф@Ƞp𘀈ȐtpltԐh\hTpplȨHrdfyưtʞҠrvvnxrd^fhTch~xbSXFzzxn|fV[xVvgZ]jbyO|zTLyh{yb_gdsxloVhIi~~zohcjso|M~}}}cđ߳ͩʨʶꚡ{֠бـ𰘚rc~ڢ䅒rЎWgZBD8T<R"6,HN|~®nzrdVNVZhx||vvvzztv~~`\VHD@DDDJRTTXXVtp|hdlttTXHH0DL0,PD4L4(4$88$,XP@ (,\4088u P`<8\d 4Tph\8D`$Zd6( v>*\[eQxj`j?n[E9^.,,VS(&*87SzqM6>6<~J.(DH$TH,<\@84<*L <480 ,$ $@Pd@(hPHHP(DXTH$( ,,8@FZ^dLK@Sr;>@`btdvZr8V6|<.\L <($pD$@ H0P , ,4 ,8$$ XHCd[+*G+.M2a26 \YD>K $0pX ,<@ L48@08<\LD<,PH$$8P@d@0(`0(dPx<8*qU^`t~Q{ȗЈ|ƀ鵝Ǟ~՜|ZdelvrknxXxxtz􌠒ĚАԸȤ̼丐ĸVجԂ䆊Ц̨蠠ЈȀ򇀀Ɔŀذ̼ܴ踤ĘԎԸĸĈ܀謊蠨̌аАȨиރ悃΢ĉЂ°~܄`xjdeXpzpf]Y^lbM<@FYgUSgnV=BVptY]deoswEggs?\H`mn{Vxb0;rj~l`cFjS#XlX[d{nrzmzlX^Cdz҂meFpdRl`trn`kxxibimnJDVRPPpMZt[H\pVDDr3dME4oUcm$X˗Ԑǭ~ϧф؝xXbcjnnnvb~~^fX~vT\nzt~v`ptp~rzz||ptvh|llZpn^xl|t|XLtXph`txxhxtppll||hZ`d^ttfl\XTJhtptp `pPxttt ppxlxxxPh\TlvpVdvqyxvt|ww|fRHZeqprlxЬXxX||xtblrrhļxHDdLxt`\\tjhlx`\X`xTL`p8TdlvHlzklmmredrr̐xںprzvxZthbf`|~ohctzUDGM`qaaynSVjikrp{Ppq~IVlxwG|y]>ZnTRq_wt}JRuwP}gnaaqyvcjPrԂni临̢̈˺ƀ䠭Г̢섨{^hcOMJM}fj߯쪊ǀȂ֮̏Ĵfpzz~^p^ntfhljdTX`^Z`xdlXh,P@X8. ^.2< $0(,(,0$0 $<8>&DC;2-42U6Dz 8D$TIU0$L<9)EmPE?]K>[Y`8>a^mVxG`uYzezunĕڄjgVZzyvhv\lb0D:`d TT4@X\Zhixr,vH0GRW5YW7(XftncojeިhȀj<,0&xP`jNLjnnpĀz©Әr܀tzʴҮrp^|~`xpX~nr|n`v~vV|~lhlpl|pzrzfl`rxtljd\pptxZRVXXR^bX^R\d^RX^H^Ll|tx`tTh|dphxhdd`X`p|xldhxxpPp4pfzv{^rtplt\|dlNZHabRZVrhpt\|lplp~ƬxjD|l||@\g\JucKurwOStriVoW{ksrcg|jyȀЦ좾›Ɨ˩Հ׀ꏷ֨普ԗ޷ƄöҴ{Ȓk~lvϵĈޮ󀀰ϫˠԲظҤz~䔌|l~R^^~zXXHTZpXPb|dlXXLThHHdhLhhPTppt|pXX|l\d+B<`DJxpJBNLxBJ:BpNL|p>(D6.(8TD4$  0 \ <4 $/8l=b:C`LV@Fp "0(:08 8$L8yj^U?\^~p_^.BkK:degzE/\J`B.Rg|~ؔsXzz͉uaw\~ħ~{ɟlt޿޾܌桺xkvXeĸ~xlzz좒𠈄Ȝ耂܊ԀԸ숀캬ȨĄ؄蠤Ȭذ̼त섚⇍ЂаĦ֚ܨȨȼČ̬莄Ę܄ȠȰԼԌ̨ؔĶƘԤԀl~p~ZRNTWT;h?IG?PUbwcL_fnzPtuho~rwv~Wipmlf|L0HNHJVNJ<6BD<6ZZISGlk:18rT[nZljobXBcbxʑ΋f_,<Èo]LvwoT>z`jF4Zi|v^‚ؓiٸ€ҪDP~ݿ޼܏ʆ|ihOYxrЀvjZvV|jZxzxĈlf`j~pnشvlиv|мjhbfdĬfPRX^XPV\TP^\RfnVp\ZZv\PdNjtlptdlhH`hP`d\hplȐT`PHXbl|tgmXLxhttx\b\fRbx\Xt|P\xtnbnt\bhddtDpx|`dttdnĬ|Ȓsބrsgvl|]VT^b`GvMWRHFIcWqk1Fh[*Z=;LVOY=n_RaffWJqrpdobZlzoP}QjiuRWcQ}yuwqowJfarrBsq{x[|ziskdi]TVfjwuTnv|^~rt|Ė∬Ykss|~ĬĦĦ䶩⻷̵ܟ߀ѩ̾쨔ڶ歀Х݀駃򆚤Ăܴxز丕ΘutҜ«ʷٮʀɞ彚Ƕ䜲ʘЄ଴謘ھIJxt~dxllԈ~ȌȬZtbl`ZVR8BFJPNHNTLHRPBV`B\HJJxzdF0@&40@DDH<8( 8 @4H3 $,, (D4$P 8=uN>.>P@8Xd$F  D0<8P@4 D:.8HX(6 ,8,200`8D,T$/^pR@f|ĸeebRzDQq֝z篹knڷvzjtbfJ^znpdxrpt|؂􄄼|Ў܄Ĝ¶̌РȨиθޢȊ薬޸؈м욜ȖԄԆ܈rĪ⠜ȴ޾~zzjwdr<:\=79ZjH^nJG`7-,NF18JqGG;DHI]MUm^EMRehnbfX_u6HB"b`>D(<.D0jJ2X,D"*mG9P6QT:q6J/j>QI_12:FPeӜ۵氤{JA\X<:HBURPBd@~`j`W^ZvAbHwcHlkmhZ|DQpЖq~ƣnjع||jr^^DXp|naplXNtvfb|nxzXnl~~Є|jrzxxvnĨjrhh`jĠvĨ``Z^bbb`dfnb`bhh`^Vdbbvpdxfpzfpd^jj\Lx`dXdptd\`XXd`px`dtxXh\PHHj~lrvzJ\TbxdrdThĘhĸtvp~pvflp`lzxj|̊жx|v|tdq]pBEgJFKm^wd`yPE?`fCJ]YXNW[]qbhyiPY[or{ougqLlWatVox^]oQ_}\Q{sxzaz_Zik[VejlbLNNvPUSXxedbKeQTVc\dxi{rkvulhޠҴxaDl`U䪴ԺڼŲŬǪܙΟȰ؁얲䢉АжҮҸ抺ͫևۀƳ췐Ƅήʼ¢čܼдƠ֪̼~v̰r肊Ȃ|Ԩ~xrfjjfb`^`7h^`bfldbTb^Zl|t^N`|TZ`UF^T66DDPLTH8,($$ \L 4  D4L8H $4(/8TN,$HD@P8d((2HN$,$4TL, 4DlT<@@<.*.*$HPj($D@*83;@) +?M;3+HA2#*60&XfBӧ@&#QCJ9j|X,WZoLBJRL]R257RR@IG?210$#bzdu=Kfz}bf13>7@:Fvlu{\׺ikƂÁkinF\lXrV~nfL~rrz~|~؉ڦ؎ȼؠȎ𰪦䌈ܤʬԄ䂀شtyk]`bkjqƀdVjXlz`Jx`VB?YVT]lc@IPL6V`*%RZ]rcZZzVfbCMo_ZsV>lTMkUs`buRCcg^3R^UK%wSly_z{_Ystmr^/tQkfCKxpR{RtLxsBxscKE^SL>eX@?E7bYmxhDYQ)8JLJ}vw[j|nzvv{om܀~}xb.(PP`p@<LFF844$J*^zD("HH8&-OMZbN,0^fL?2PK@"90/$:,:HB*Bv8~KڱS;8hZVOj>=44u~]dy~htCERNVLVnvzLʧt̤r̊ˉoipDTbLfJrzb~Z>D@B @ L48$<4,( @0<.88>TN>hl^PPzV`bVPLNTTPld 8$,lhJFFP\``f$$LX,06BL.:PFB>8`l>DTRXH8CyVoeIRN>N6AqfegihN|)'SDCUu4HG>8'((=.=FZ8(6r^|LYhpXbKID.bjMPnGbew^]QT^x_geYmZgunnljbHO}njD>:4\2TB<`N6:LXXD`:>.2X\plDh@JPBL2@d``j`j|jnnxxLK~~~xZjF@`PhZnfpvnlv~z`ddtjffhP>jbbP^XPXXNLB@`P^HZhNZxrr萠䘲ʶ✜⪚Ɖøȧƴɹޒ˼ƀ쀀耀РҭݳƘl伃 ~}}~~~~~}~~~~~~~~~~~~~~~ ~~}p ZNFfr}hDwf|H,*("" 2UHu8ALjaWH9(,( +%2)6990$ " $ !DlT_WbWejfSWRa[dTORHLWg\bihgQAO+-FTD>imfumkk-:]$CY|xzdDZeWF|qe̯Ĥr|^t\nR>~mPnh~DenENFPhH~pxf~nf~Z\|rr|Ƽ޼̞𦤖ƼШȤ򴬪꜒g^jnRXrqgdN\Wi_=O?6dEK\R]i@4Zm^zaN:SNAbk:GFPX_vLpNVs6EXCb^ZpIicupllZk^wTIlVvuk`nw^eh>\[rujrkuQiisCrwpzvwB;T:68B_TOLXwncY>e^?WaV/@H:bdbTJ$0@00P0==3PH15v^}ttz@Mo6(&)2NfžtfhqcR}kݾڸxʒdx]oXDoTxvIjvK\LTnLp|p|^xh`|xxSVpzf|lzvlzzjt~x~~jfjtzxx||p|n|vzxtxnz~||~frd~mВ~rr~x̄zvlxvzrt~|eXglSYrqfcM[VjdDUF>lOUf[foF9awjmZI_]Rs}N[[djj~UyW`}CTiVwsr_yq}}mrdWy`{qpwyOmj{|WvxR~MGnHMYY~M]RYcmk;XN|KVSinW<4Fywdzx{}a|p`܁ԞfcjNuzeАָ}ȍКАÀسͨ¾ΌҦȓҟ͞њ„zǹƄغړ؞ފΕʀܼв؆̸ְ̲¨ĸԴt|zpj^^bb\\PpXHj`nr~RhVTpbT@DSDm\^P"S(/%'$  @=$ +,+&(<:J6,jzDO{@kv?yVQLamevbQQJL?:6.&%''!":hH_Def}nbSZIWvʩZbaWx_MXYOT^clr|wzTosGnuRw|{[jJO`[kl|WmsEIJ{OLvqDm\nyvVrPOI|~npTV]T@eg~W_ZdUTMLPlRop``nYHI+GAdW2(<>||ࠄ䀤08,2X]XNcV}ek|I9E|@Qj`9dYcGQG=OpFCbA;H0L<@C=&5:4>$46d^fH*.jt>N}JwP`\Vvzm\[W^S^;THR5H<-CGFKB>A\ezOno͹idcX]}յz|rxc^eyN_ctxlߤůt|`i{VWwSmunl~l\|^T|^[I^BJSpz|phzn~`^a~R`NnjfbVzvtYntpxz芌vzz~Ȁv|xvzȞg̤`ipglX`}Xg|^N\gO_gTUF]T]aTTNrvvz[X\vpd\[PSem[X`XiohgkSelnkXeSeyt]cRSaa^ajgAii]{YJf_idfbZZTnladkmqw_}Whk|Uhxr|_vPUX\YPta]\VjnwrQsuspoheiiyzxh[aHjTQwTsxVS|{Xzd{Xwp}i~]rbSuyxmdp؄ºԲpnPܰ쨦쀀罖޷ɼ׫ƴʦ⬇݀򅀀က̛ɶîީՌʟ⫘л͖ΝżƶΦ̴΃怜¬܀ĬдĖֶޒּƐrztzxvnLVFT^nZP@DDD~VTԁ⭯򴒍ܸ惠ڱδĞèѷìƣ辩Ķ޸ڬ״¶쀀 崱̷쯻ɿîЀҮ۳ĸԀԹٿ֯р׀ž~~}}~~~|~}~~~~  +~ +~~~~~Ԁ +HPF$0؛t\JX8[=ZQ'd~CLK!/*4 +0?- $"3>&06F2BOWhs_WanpSR\FT@NZ8dLTDR$ :NH,0R0LvDB7PusLfwen[GFFGVwO9L(=R84g`Uj~*bL~THTZOyQX]ojpzdbld40^tv{}|zwhtnt{\zəhntibbMjLIdY`[__vfbNXn\OTLVeRFvl^^n|N~ZIf\|z^PM^z|ИƬv̠t~֖ސtecUjdemhkud~~qHdf\vy{qqYMpby|W`aJGYaJv}jUhSxfijcvaj~e[^eSk_hJ`o_k[}xYf_`k`ZBuxvmMevPjgtPPmWrd\i]QV^lxc{~_kd~lu³]Y\p}x^hhOPkrnme{vx|{qpN^ZavcYl}pg_=|x|]|UQy~vTWWsc`p}[yzkibxo^uyrr,(Ĝ RtPؠȜ逴ĽÕ셖ڸബȈͿɼճ£αǺ₩Ƞ식ž՞ǀ倀јܖ쀀݀뀌˺Ý序њ܋܁ؑꅕސ戾ځΊ臄֬ԲœjPlPJ>LY`\nQG[LFzmur^ts[COndarHgwzupmZslsSE>>x{~谨tބϬ¬~vuZltuV|_S7=sqJHHzo]jQwl Q.LN9L\NXXL6F,U?3D]>N6BGZWne\0L{kȉlkmץ~reuHcv_vL?,29RuxrxNJzǍ~cjirseaggmeRfbldNypiY\vp[eEWxXVVQJWbXeRfey{pihp¶̜¤֮ĸlnΐbytlq|rfYblYc\\wpXvWt^tUxk|~yyx\intohuTxdepqmaYtnW`sRyrrq\KTPVncmU]]zwMV^X|s{ƕ}ǚ~uʎxgƇe[\Sn|x]t̨¶Ұuqnhj]\fQGZO_c|PMsNAm1I?ODVej`x]ShZWz}f|bKWxro|Nl}ygv|VKCFMovĆvvu|kh|}:؂πÀ쀀Բ얅Ҵ΀즞ⲶĚƟ̀ڙ蝰ǨϝހɬĒﳖ̀ހ㴶㿼Թ̀Ǣזǫ쁘셸ԞҠĨƨn~N^hnVwt܀ļεրǴ ɟÿέҾԿۀ݀ʽÛ𕯠ҘΣАԝƨޤ黡܆ľ睩À׀ÿвٮĵǷ巈̹ͦíĽ뀭 }~~ ~~~ ˿ԫ”̙ٗttzӃ}SbBD[E9, /@!, 0$" 701) #" .$ + $EH.,(V>d/$.)"3 188S9JP"1``Z<FJq|p^xG-+*:srYYjT8+SD,GPo}qws|skuhUg`M{[]\W~~cZWXY|f{xmhfa8]EB8MNuf`rVD3XqƤ{θx^vn2<2F`s`b^yДed`j FxbLFnrER^YdarSSN:>*ADA4">03:IOt}YKZS95w1CJ|{ZwvcmY{ItyHg>KgR\rYduaSw\rmusfmv|LQH"2YfްÕࡳtvxωWWp{b087C=(:@g.;HF^~h^rtlfkyΫğijЧjdd>jZNgSYfOVPgrquzjvxzt|̠Ȣxnмvʁ{g^eg{eo;`puU~_~ffx^{j|agnʼn|x^wgSotwcya[]fn{QvSPTlnei[Ow{ċ̺rPLPWsuqusx|,Rv\>4b|P^k\igcedTxH`b]O;,W8HADQW|aQa\@<(#}9R[|o{hWMoRVr\dx_i}kan~txTXP+=ev7rӚћk|pkyҏ܀ڀɀՀۀ񄀀شԵ򘤢ߣՀǏƦĺ܎ޜ̽ȪĽނޛнߖ՛ȅĮȲÑ֦܀φ͓װހ҂ݫʾڬhxlX2^T j܋ Γݷڶ¼؀߽߲退˫ۀѬـص݀ʿմѫ򅧟ĪȟТڧ̀ƶҀ㻳쑻EТ܆ŨΔðȽÎ +~~~~|vqԗ̸ച̍ꡈsˉzFWs5uB:,0>B(0=0@E'>,+#9E=;1,  !**XH&B1J$-" +G )K= $8,(4@2*(@>.1,>B41&0 8 !*H TJ6%.#>&+42 8726$T=%3:@[4Lb_AI5+On?"+(*66PDYJD^`|vpr{vНޡo`dAVQ3zpGtyu^\[HMO|oSORfQ\q^}wsѢƥ憶ܨМܺƇҗ[z&nNPVYRw]c`{Utti^v`hjdvZK\z`kwmejjnkg*Ui|pygYzWoyfno=~QqhwWtr}Mrhjuyuh~w}ZdUaexf{PzţwH`nsro|xpzq}rz}mr=bWh[@`SWVnk@Lz5NRP,WTKrSJg3RNHEoV{}Xm_ryaSp}So|j0PLbdqK},[[GM`e}zܘԍ蟅pËL]PlIIS'X_cP]RJMK^=3hhKWMI4,6.599CE1'_QDhI~bD(OB)/BBR?A50uNLUueC=.04:62@NB,4!8:S$TG/)24C,BV4V<:!6A#2K2/5?c>0vgN=C7P64&(7LN<&ZB-I)PTJ|S5@QY^uN[|_fUMq\48=72<:ZI_R`lnw|~{ա|TdB]\BWjjjPWW^f~j۫˪򌾛Ԛȴ~tȉ˴oJ^_cf^nso\|yzsfqxRhҎiРƅtUZoZyym†akeimuXЪTn~̩Ұ|ŀ{Ei_yn2SpccbR]Abb^DLX^H`Rb..NPxxh@@t|mȠøhR&APC.<`hg=]]CH,N=2K"IN]MAZK,G0981,*E?. $ 3#$#"D$O#4/:D2"1%+#?#'21>(*8 CN)'!)%7F*76"F>)2!19&Srl,A0@F74YH=.KIEDN@,6yE(V6:G*PDNPD82":?&:(/D09V:.5=ZCZ>Q[P0/3WM8.<<.R3<:'GO9bzJ-9Ne^h@l:cbkF0TqRkfms]P`Jzlpīe{g}oʟq҅bd@HB<(Tg6Rg)*B-/EY}rsM4L\r_rکП𨄄||vpҦȿ—։Š~rqXh[^bid»ɡcbdժЭũokfsvaػn}Ǯxu\ɸOz|ؗŸͲmzd^rXgkrrЫyv=tYˮӂ`YR?\d`|w?A\NR0$68ZX\zlPLP|ö`v}tpddMuz\cmvȧYXhd}şqusZymaXX=od|eȸШ`쌠峀̀׺ǻ؆Ӿϵ䱡ƴրԺĸ˫肪ǀБǬɫрӀـÀʔޑ֏ӧÀ֒ꭐМvT?&"6:#!4">((+782 +! -4# !"*@""!22,PF: *4JFIC &!9-M54A?@GyAD?I}W-AHpaRksi)-H5;.!2bD`l]{nRjuE7Ur2F3aubz\j^Vp6;,#`T'QKD9%)MOB7+GiSkmdnǓ̸||脼ȎȜک¨yu|ȭsVY~eWVexஎȠ}}qM[M>pm`[n@fTXmrΪ}wCB\0Tkw[rlzµqh?B?U`\ůhk~_^?]>tzeptPPdTXn*HN4I~VrvzoRvvp^QPusj즉ܔlldOjuvk|kb_;MSAbIr\^TBkXrpbmw{v\n/;BD7mj~\_h_L;UY+.db*NV@d(hXZd`hHhXXpPjڄވVj78qD;P\GpWj\h}{^pdXUZ|`BhPNtEH@ETO2(BX69RD9EDiUUXbE?Gm=.8b"4I-+(BE5+02(!*3?.CK^6510PNHbVH':(@ZX]Y7>932S04BfS>_j`+\bUWR^nCBX_jYrIOkVWH9IypjrɼVpQB`=L5BGtdHfp\`RHh7A73sh-XycsvmvǓ֢𐴚`zrʠӻѷ˩hhi]^wĨ⫑hudR}eúc`pt}ͮ֯~z\XrEf}h̀ŕĹtrLQO]hd˜̴yMeBx}iu؂ZZp^j<^[DZl~b}z^\{oȡ򬐔xc}ŔȷxniH_fRrXmleS|jmytQ]aaTx{yfWty?N/⨶ИԔΎpr洰ᅀ ɀĮ񀳢Ŀ򩭸ڷɫչӿڜִڦրڞǿ©Ƶη܀ƙؑˍ̂ʢدðܿ怀ɶћDLDh@82@8XYt.LWxSmP#0`fOԼЀɱyŨ˭ãɰπ└ԊХ߾Ǫ񹀽բ^elŕԦ͔ӭѨ欈ԌꜦ˴ꃏڡոҾj}ahlLع԰њӰɺޠ~~~}~ ~~  +~|~~~~~~~~~ՀEN"* @&>.HB2[`r#X[dYXz|gxR_aU~p^B>[-1pgiqTP`r[g=gjy[T}Z]):A9#3369P@8ZPf:dHRKVV?I~}DM??5:=nr/:D;*5LGU;83Cnnpþc`iggêڮ愼؀Ў؆𰤶ȼ∌ƁϧylskpqSE~XdZx`tͰÎbPUSShginAnhJf|a}Ŏki^ZE1/?OVSM^Sd}{·KzL06h{nHttbtqƵpl̦w`j.8tKkg0IyhmH@\}swxyy̍zxm||_zF]lqizbVi|t}dWggpYF|W^_^HgfbtctX3cWZKWPS[n`hi]9=g7<98AZ,P`46$@ 4$>:.:VR@|l(H<6d4|𼰈<&R2x-F=/Sk^eCciGReQbN\ddqEBL`k\46`rlWTETVHG?L=R;2i@FaH0-G.0F#*=E41PbQT=:00O]14%8D8J*&,.+XOqS@DFC=);Af.J_>egncdbnm_~USvP:XkZilwLml}hen5ELC0)>8:?VB4RJb88svDjfA3'$"0"91@\jKPVZd,=7O@E98=>=?N=R3n6,GLDA]OMf_;anc]eqp~rx|䬎҈ڔƆԚ|}ȨҞ`glR1jd<\ERQ\ThsxЀѺcpHoNz{x||caqJQ!bd^gZ_Oh@;XLJLYԟwv|iuʆ¶}sPЄ|f΅fֻȳvz8kY^\F*.42200XDThp`LHڒZ0J+}!$3"14-UNjnH;\`oOvvuO^Jdnbi|\|hM6E6wE>maXPEPnMt^xL,D;F0+9B;'$1HPVmdMN55AB6n2JbsXQc^Yv2,4GTs`rae^-`whjjj>;<=*`0{K8hZq_iCu`nIJ&ub}zT.A1)8/'?+B:NbjJNV\l2FC`*NQ>;;<:AUIdJEK`VIDaTRokHpuicjtrxxvАzp\\|xhZxؠţ܊ذްk|}`>usMo[^]obr|̊Ŕր{d\˱~~\o.+gd`i]dW{HB^SPQ]ء~~vސЉ𐸉Ƌίֆc~Թ~ѻudsdk|O`Po\wfJL{l}wĞиht|emnz{wڊj~_wxthdpoxfuO7?Y5%F22<=Q8JCSHCiM{ʼܰ~䔚ޚ鑒ŀԮр޹̀܀ڈ퀀ϴ˺Ȯآ젦؈ԺЦԵ̜ڀƶз𿠀Ժ;̺ѱ䀀抸쬊|x@Bb\fd8thBj=16U- g#O Btvڲ˸ȝ軭ѧ؏|hgYeÅϕջڀꅀهpxhf۸̮qdth|̤˘ؐǝכѶƒ옑Щŗۮ؎x̒躲ąĮGưŅ՘ʍ앬̰Ⱦ~~~~~~~~~~ +~}~~~~~~~}~ ~~+<6:(:44DX^$2$6h{Lb8`XPPȌNT"Oo( kT~L(KfHGD>4- &?*E<([l򊂝ֈ6"G':/>3gM`SG8.7*1#2GhRkRhLN`ErqDQR~ŴkTUKV]IM:9*(Q-)/3FJ40Wdd<Rh-%!*)*- N\L2`ZN%1PV;L1 11)-G]%V=2\UIWUTZRVwYWK^etbldypԀҴư²ƹت菞Ȼpd2B[Gc6\V[_fPIRz{V}MjQ^✒{ͫMMeS|eנZT{9SWu`mT}Zza\nuoZ]RB`[oYȞnl~|}~¢ytyds̢щdǚ|캐΀ʀzz>T|Zv^j6ZY5Nzkn}aVq{irrhd{nen??_yGSemgdyiyUtonąlYvn[yKospX^F*%"":8.,GV, %!1*O7S!%.B.(mzܞ^*@iI:NWfG_~\MU[JP5;F!+_^lj\|_Wbjίt^aYg_x3.jD2086;4;XZ>4Yfl ^4)%+6)87::bhP0^ZT+9[gQ5^>)53)/Jf4kIOzua(bXV^V\`_Rmmpwj{p|t|Zlz^j|Xb|Tpztf嵳ɿ{pXRhMtLuei~g[be``kүbc}jvڳc_sli}c_gc|~mmZIoj}\ǼȔԨyv̨tԤ΄^Ř|Ɣܬ˜VpjjRpb=Utk~rm~Ttg`yvOOmS`r}}z{c~|ޒoZwo[zKqxyiux^@<;ROFGf{V?<9=>HGYT{NW]jokںҒ֓̀ѳҳ~vĚͷ݀Ġހ€ƪ՜ݍ兀܀Ǘ΀рڧ؏πԥɹ˽贘҇Ԍ¦ԽֈĤ۩·݀ҾܯN\fHTh>H`4HZf>.J6R^f_cP`Qn@*=Z[PDbb΀СǀĽآ掕^bAvcgӀڹƀ܀ޛź贶Ƨ֨|ߢӰܖֲΦsО쭒ҷȹ¤ǫ䥕ȈȏЈʘ²ð؃«܃}~~~~}}|}~ ~~ + +~~~~  ~ "20$:(&FD *$d|dn40<8eJ=H6 'iDR*$:6(@&4BH1:7&>'#2+81-*!<4./+,&6<:0""^,1!."'E,+(D.,LPB,#$8U%3Y*+20;J;P9@:`8[MBgWStX[l`w[Vl0Fh~^Fh|Ȍ̖ʕ׮Ɖwh]xiXpIMHXJDdf?sDM`JM]>df壺Ȱ䪠ǙІօޭ|p{~eViZH}p}PzWL,&=a7'qrWuOPIMhwkke^f~ZTddtb]9j^pxYL\u~copaoMPndx]YnRQ#S`j`LAQsJcVmbdyi}rT{iXSMJbJ0^alWK7iE#^3 Q@ )2?)-P-#A)%/B925PF2D0.$PP8$, d|phx@:TX\lDH`XJMb0'8I2%$.5H_)=^i{I4Cs48R?LINgSl|LڪqZQ\Y_zX4cfjfbbĝig0LuCxvV45:A=8$uw*==^`ktL?FHPj)=3JF8P4LZZ7EI=Z4FC9VPZPH//:/J@:9120>BD;D9y(CE),D'.3R==H^<2NRF1,0HjBNp=9<6>LAZIJG|G{fNl[Y{`aqcvd_}>Sr~^Bdr\ĄČVTTlZ`dpZXLrbjҫ̌rfwoY]Yyg[yySPgz__lVx˳໓ؘ|բpdyV]vjJCW|RBdZ\XXu|Ǹwjchhlr~tVZTy|ǮĬܼrkimplxusdEOKzky{mgwwksPSsiedzaa3cq{rjPcUj\qfgðtbta[URlV;kmzpkYhDQ1)re7ZgM>?#bO7V}]Wydcq~ҲЬ̬ԴƯ𔥼¯ʀ̼Ĝ̨ԇ¡ִ఺㌾฀˪Ľ줦ု̈Ҏ詹ն̷ͻ̊䖫©؀线ڤù€݀퀀πҌ̀l̄Čll626N:H>>.,:V TRJ\OgwN(#HOQ7cZhY|䀀Ϯ qnif}ڦryȌŀԀﷳ׭Ҫ˴ͼ蒔߯ƨʤ{wބԣӶ̿ҽŻж̲쉋ڗ̽ôѡαޏνέڭƿ¦ζ~~~~~}~ ~~}~}~ ~~~~~~   ~~,&0,(8*d{@d8<62H$0$@TTD(&LP +8(>::2,285+/CJ`4L`dtuwoHE{KNF}MheXIIjWEG^dgSip_|OF5'k^[o@<)@,!%0*%?%X>,&.?TgJ'78 26$:4<2<$($'*:S%*  +JQ;C'-4RT.6(!, +!$.*,17<443*<=G,cI>;UGSDA:A=CfSRSr|]rsst`eKMsc[.NgWfZVȀ􈂆ܮҶꂮܔ曒}ڰqrfUo}HMPL-7LQ@;X]TAuf|apr걀Ζ{IJ~xwiO{j<@<\xkYxSyQZ^_PyGJpkR{wgdN7Zj8MXDjptZGNJ/Q:N5/K:Q/[7U;6J!1#-;Y9H!)%U=,1A&-61(&L4 2$< @4.4:&Vq8lHPB>`8H@X`4|\M]a>rDHPDX`۔XV]`W^xufVUlWOkhq[lvb\XH;}}c_NfT31RNWa<=0HDJ<>%.124AEVqGR7;,.C4[bIQ'35$:ZdJ*OD=E%*,.##:04694LScOeTL`MXIGERHPqrncxzlimSUtkJ`kXfVP||HlhfnnffVdr\J|lȋ㜗șy|p^ZppgGCckO[wxlT|iy򴀒lfԤűȹ|gX^]Ǿ}jt_ilm]^\zdzxaJl{I_kWzdPWS8FfLFVvfs`O_cqڽ[tŊYfvWT[Pz^]Yilv^NoVNTW{`jM-PsZXPFQ_xheo|whfsxgifmV[VXKj^UQgQfMEdW?s@Q}UqIDfAXO^i|HXCKH}g:A`h|DkwbȤĆ踨伬覚ľ؞쏍źĴ뱵؀꿭ހ̀Œ俺ȣ¬ĀТ҅ށպʲº腇þƀ倀濧À怀¶ҦHfXLNH<>.44B(R(P^1D>;Y~p}`}x41-n`Հβ쐢ȉԗ^bn~ƀ{LA{gu˾dgd|؀ѷҼŵڢڿрπɵԵڀ̷µ޳ԹП̎ƿʱʹ׽೸~~}~ ~}}}~~{ ~~~ ~~~~ +~~  $$ 6($(6,8FJ0J40D`hh|8flT@0 ,I$6=,40T,! +@,:!$VJ,@d4t0$-;;B*T4 a^TqDGL[N~$I!X`A`QU|i4[Ly r:Y_ 9dKE[Q88$^;= +!"fUBT=<7-&0B4,> "D$4&+--"",B +/$@.P89+D.D.+. 2#53c+2/0:4>3FIVR\,n\YCB,DJdtEooXMWqQZ]~T=Fen`iZQu9`a:LJ"XZhܜܤܴ𼨤ƼⰦ̤୊~̱ǠǂWarTfvU92^e6MRLCDD=@GD;u1I%3a}XfQ=G`aDHn#-2(7WB%oZbV`6O7=M1c=&bA/8I:0;B@," 2,8:T`<*,2$4LTHXPL\|4NpH5!,^M9,,:aB0<6lV44X$tXX0&8FJR>lM>uTh[iZ6[4lkUu[]p;dV:\oy&Zsr}c`K?DddD?('0*89ug{;@7c_V*?&2\"4T6L8HG<5N'3\S=&C-DY'0B+@1JB`P8P8KaJZ@54%8+@Bv=(82.96C?XbvzIndIF.ITuQh[bzZcf]IJbdizbZKqqF\P"TR|Xhphxb^plxzДvn㱏ހϳͩΎƫ`kexxVMxX`JetuOxq=2Effc\rdЧƤfឳvie~bg_XdaSVxwI|iTdgIJd{sdf_nqgv[MFILWmJE]nqqKNO~cloZ\bG:6L@[mAG3CLW[X}S\APvxpcrt[fszuD}JsJuZiGZY\`kbdawpTXlqjaaaY]caVLfEUvmXeglG耑ʀښ蜘ȰtxD8B:@fL8R82P@d4(DD kAzCfBN6,$O*L8Tί ʺ܀ɍΖʍmONmHoqંN2xyچܩɞѣ􄀀ÿ޹Ƚ»Ƿـи֋ġƝҲIعƿ䝴ٵڬּ~~~~~~~}~}~}~}~~~~~ ~~~ ~ +&(& +*(0(6@T<0dD,@X\HHPD>@&0,264:"6l" + ((,"=&@D(L|P(H$ +*:7R3 +/B(!< K2:VTJW[O_`Ko@DvN "46"+Gb..:C4"!S('*+16 (TW  * +7> +70F4"":"2&"4,8(  +)!'!T&*&/4"7 );92#/#1#Lr6"+.4.;*=;@RAXK8^=[=\YndnYy`k0^TJQ0?`dc1XL5LH:N,dDr쀄슒ΰВã}|vV[_`]gKY'PF@&47_Y8b"M0"2qtY[\äHlt}zsr{{ZcWHq_|fT:B6G`PKdwCG]kEEJwYSgWMLFMCBY\b7]j94'X@92359S#2;Y:)2FJ;I3NrI7L[="3825E7?Av8IpQN<`3,7=I6UM2-$^-:LLgy8fUEY8SX(Qi8jL698QA1X2(44,&6L*>*4$:L^* LP\@L@lP(4TPX0,.H$>.8,HZ8NR8F~8&$@/HPG`E.:PT0PT4P@,$*HFB\>+H]HAL,kOSkfQ^kXjlXMPY53RA-6VtCFp]P9GI}WZ`dijJ<(:.Kvw2(%(*9>28+8[=jDvr~bpXppd`frzpn~tp挐ͬ⊘`ekpo{^}IpgeN^MI~7_@3GhfYfڑ÷|DȋƑgncdβͰ{iscJREXiYUoLRiSTZ}wi__X]SSz}X}KPeuVopg^\WUZ_\R}_qMfNQLKEGdY/ZLMN*<5.1RUkIqRRuQs}m{rJg]b_:[PnhKMOP{GDM}]LScgWdOigYq~`2FV[SWfX\QXi`\IzPIW^l\|`WN:E}KUcVoEuh[sFqsCl9Fj?X]JveEjޤ𴲂Țܢ츸ĂҘͱƯ򮀙έȀ˄ꀄ䌣ֿ٨ּȰɆ䑕ꆓȴǯãܸѱ􀀀١˦ƼȀİNDpT:@DL`@.J.\:*$2bA>dY6-8B:6(%6NRAR$ГɀխؿLʀŢϫɠͽRsbczL.|`JMNgvt~ԩ 򀀀耀뇀[׀ܤĶশɡßүڷބPެѿﯾꀀȾпϽՀڬ̀߀ ~~~~~}~}~~~~~~~~~~~~~~~~~~~~~  $& +*2*8(: (>hHd\x\xPHd (d@ 40 XN2 $064L48 , +4 R<:pD0<07:/,)!;K,!"doENLOje#.2!,?= /J.369@0NC!8XIT}Gi[{fWdT\_N>lcUE/G_B;JtdX:5Rj}F5V0NZF|쎐섌⏦ҦĘྜྷ3Q\Ox_]VGa`LcgJaC4\^c>aV@aBJF`E3".,D7n̎Рhؾn|uze~rL@ICzGCjaLLaS3>MEpuG9g9fh=AfEvtCzDDkfEZNLwRYVuc~VvVOtGG0B,3U7AJC$q@YFEW7S2U.-8,>.(VFI[OB7]d\0]==2LM\1B)-HK=V<636490>Q8]A*C=JBB1XoWTTYJ_N]:U\[_=@O9RT]8eBAluJLmBcI?>UfFO=hgB@FDpFD;WN***20& 4*<2:<*D2D:@L8@082FDs~VYZ_{^vPE-FBC9:9Z]kQNJnw=U2*1.;&2.D:dL62DFB84:Jdb:LL0xhppXlXh|jpllȤdhhfzxȒ܎֩ɡȿċ˨ƑAbnfpqm]b`lZ}saT\TwW@)2.F7p~ttpԾꆜmޚVJTPQL~veiaOZlg\OQSWXPQQSh\Z`hdpcaYTiWUT[EdoztVY|}XO}F}UTaVhU;LvSUukbJJvxOJtJHYKEL`{qEqPMjPnyaYRU_UXN:\nV|PHaXd\ZJoc_`oTq^lBemotIYjFs~PlT`_~TxbMMqaiHNLQP]QHpjڸ´䂦萨܈謄䆚𨈐ବԀЌ؟߼ۮ侭ѸۀշԷ⅖۪ԶŲϯӜ𥡉ĶƩ 脀øʭ⑴ު茴t|hlxpRVLHprT2860:VD@N,6nN^IN;-0QU"7.8L +*qcnۂрހ[èකPZ|\|ԾOm>Wnfz"Pa~Ǟà܈jޞ ߿꾾܄쀀ڀʼӯ޿ʭ倀πހǾကؿƲĮ݀ŀ݀Ѐ؀׀넀̀~~~~}~}~~~ ~~~~~~~~~~~~~~~ ~~~~  +  +$*:$L,HX8l8608Hl8@"/, %!,O9 . ." =+HDF2 .4,?## ,O=*,!IS!$=$**$ 6%!F-&$,&:8HH422HBL^:`L< "223@:7!:c$. 8,+/0;&.9,BV9>3,:974@)MF?D]_mMRnPSei[WY{NXP`CgXL?]NDPM*4:S6EOVDEW@I`EBgZZ6QW1JEKUX/T5T`3R?WDQO?7H(;JJ?:4,$8<64,.(@*$N48d8HH H h\l\tLH0PhHPX08.2`$xLh<6(NHhj4:40F<.,@d>T:0 +4IG8818:J@@3vL17N5@D0>BDTFffiXJZAB[:@16):"EYOYJ4SRGdxa_OGjn9B:HNFF,3'*4JV:XAF=-Z>B<.6,@C6 1PJJ+7.^кˀڀͰr֞쀴ড{roBKV|^r_gƆr̀Ȁ瀀րֶ򀀀ကnـԀ瀀뀀က«ոˀǻ~~}~~}~~~ ~~~~~~~~~~~~~~  , +" .LhLPLPPD88<8LTD8N.6N010).8\) (@@<'#!2"3B/E-/7+4<1IG%3I058-856;JHG;FP_yWUdlkcjZtLko]WT0Xn96lX|XVdXdFX`\bblfЬȬ؀ܠƺؘƲڌЕȤde_mUVmSF98VNJV9M>1>3H@]i>/f^Jtլқ֞֔šsikXZVXlCQPv^czJm *7& 4V{kgV\cY\`zpR67Nk|pJzi;;8fHAwLxKtu;CC`JVM:cErLPIHPLX^[ue1BMB?T66DHUV6lim@lBjDaZJ_FepEskO`amdeRpC~TZsil|viegxaWt[PeDFiZYo|MvWt>LZsCBPV:3O2]O/E8[wL&JoX0;tkgn<&$"$.4 $0*.2 $0.B\r``THH<0,4@TTD@DD<8<:<,*, ^I]"X9;:fRB@5YU:=IXaVZB8=HJ16!aJ&6>-K^H:>C,.N><$@8DZ6>N08L@FP6H0GQF.5MIG)Hf/$ ,JJH-#./E9KYFZ@=@16=0HB(@).0$-,1;PU^LVZgg]lrqiqddvnc^:bwBDnPnFJXLX:LVNRTpXPj|xļؤfnbfzx~pĀlĖޤؾڠڸfqgc}zpUj[UfbOqUfQ[M^Ur}O?~lHnV^엍Ѩ؞⤌ʦƶqsa_\duN]Zdwb:FQA65Edzvgdvbdu¼_CF_```PuhNsU_ZMVVo}qLUX\SRbapzyVkwmkLLotLUeSwYz^}Reus{qo\yHep|wof[y`RjKJukmYnQwea]l`D_DqeGaSub:\~hAL{x̰脌ʋĬ敟рɊ芳ɿ̮nģȸоƸն꩒􋇚Ž΄ƆրϢ‸ăغƴ԰ĀxxPXp\<>N4BD6<,@R@DnPK?`( .ڀԀ  оާČ~ΥxvSZP}\UHȄɢڷû勉Ѽٰ꣑гՀրҀp耀߀ҀȀπȝӷ٭~~~~~~~~~~~   , +& fjh(P0%,&4#C#[<PL8"8,2D.B:8<4<8PF&'H3 =@+U07F2;4'6!G= &P4=+.ADEF9?PKPA^?gQuHtheTiYkcizyTPFjeTdr\fLVXJfFPNt̴Ըجʦ亜ČֲtwvːȔoba|raIiR_@5/LK^=O@7VG?00.WRUHC/H\GEjvdzȎҬ}憚xcTD_afiqdJ,,& bQRIexj]PF~t&STZjmHOD`wa*cctDYuqKL)QN&6TZd\SsORFXW7rOkL0CaiAPiHPX>rASGQmI~HyYElles_::N28P?*6@n\ZRsxc^jֈ8]bcahQz~J]nVfCmjDUszu^T_WlnPnnThdaiqyPfsc]\PbSyxo}fDX`PanMQ^gq{vyc[Mh6rvjwzZX||p`08nLHBC9G\>9PS耔İ􄄊젘ñ©ꋢ׀wݛ؋ܮ€ԺƳޞξĻశ㡕϶ßz鮻ܼ¨Ђļl\d||T`JRVl@BB&LvT:^zP +,A0$ gOI\bʫȀ倀ހս̠xȐ@XFxHAFU`䋪ȏݼߴ̼®ߵ᛺ě|Ϊ쫀Āڡ؀怽״܀߀ִȬ€꾷ХܨлЀӚǀƿڭ׋~}~~~~~~ ~}}}~~ ,"  + .0DDX&B&695(&/-A$"RJ9FE@7=,)?-61H8:CR3,3TT>UVeADZi}@ytpxu]a\guM\uG\bsbkFjb~YL86RjTJhndjhf6ȘdjČؤ؞Ԭȳz­{{sҐĐv`LrDCDNG-3>6rNdXdNGTWU[+)'?63;6%6O6LhWZ0j\b`Ȇ谘چҺwrͰoaeSVkt08[~|~~ieu;uʆDTMY[aSd~Zgn^}KmveWwU^Ce*4a1KJIvRlMH%xVvZj8wYM`Z6I{{HGphX^dP_\d[lE9.;+6;0&GOxaukryt~naLB}N9Z8_FzzttgzkQLbb`na\?K@9('.%5(0$DneXvH.**,( ($,4.$*2*$0N,@80&"@HCRwU4dbXT%;SR~hgt8fVBFD.@8&:^,:D;=?6[CI_<@)+<2VDZ<.>00F8JBR$B*:#@?45BDX591]SQ8QD&^jھu]إͱӀԀ㄀㸞vjhD|b2\?YRQKdʹҲ̻ߤٴ⤊족㰷ҹҖ܈ҫʫŷі䴽zľ’ҨŬɝĻ˼ݬ¹ۀ˶ϰ߳״Ѳ܀ҧ쓂Ҵ~~~}~}~~~~~~~~~}~ ~~ ~~~~~~~   ;&4(xPD@@4,(,<400<@<4(DXL((@<<<888D$(&&$*"  3) (5(,0"*$!+3-,$CR.8t"&0*6:. >!! + HM3% +<'*N,::4,>68B8@^RF|L85&9@,20)66)3+\$.%-200/)5S?'*SI(EAA3ZTDt}HSZXaLh[~XSTn;SX`edg{cv2_lTfvL^T\XBbJ\nRxdܨ쬊Ԃ܃yɄ‹pVDB2172,OtTRoAXRnVW@:?3<7N=EEO5=EGBP]XlxfR~phԤ辷~quoqepiL+6blʧlgXcVR0dX~{iƎtVV_zwXv[^snz΋uOYYlFox|bJk`NXfmN{~e7ogQGTgS_SUj@UcfgxHT^EiKHcQcnRAFAri~e~UdU-AWbDo`Siynq>oO`sgdzzqeo(&&,*&&,6,6"*4G4<8|H@<88,04D<88<@<4(D\P8,28:']mTOVf' +6BNP:2>44L8"L8GOIG?2[kF6>4.$8HH<4B:F^NDxJ87*=G5>?.6$:@p8 ^=P>8TO_X-;E0)IK 4JPK8ErZҖ̈́р߀߀ھ䆎⮖𼬸8fRQVAhsj{ȗµȩԣ־ߝҁ⨒̵܏ɮŵրỀ󉖀ŌʧȞȘҿ±Ô˱ֱֹÜÔ擯􇖠ɫɳ⡞ّ~~~~~~ ~~~~~~~~ ~~~}}~~   &l:0 4p``40,,84,$(8884$@PL0$$0,0440$,$ "$,62" $ + 1 #6&.&&$0.4 ($.,4**8B":((&&C  + ;>1+,*& D8@:6@BJPNRtPNpX?7E,;4(+*22C>+58IG6.9(c<@0<8HZ^W%wLeTj[hgs]x_JOlpmjX:%Mb[mizhĢth}obL\lbVZ@Pjl``X䴆؂䆎̔켌ڕȦԱ܏Βwƃp_&.1&CEI]?Tvp`fgkb;K83V3?25Re@/XfoOYNbGqIF"2Ćdc|ʾ~~upjQ<}uP}goܮڜbXWidvFpeEb^Z%qP7&Nefѽ˝dx|g_`NJrbaT͋nPhYN4SJcu,$"$&((,4:&"4$:.H@<hXX,4,4<<4,08884$@TP<08@@DHHD@@@.20*&((*6&.F4#K7&6M7, . 4J22 OjNAR]ZZ8B:202J:B:4B>26&00$8HPPJ\0B2.:D&B4*>BLX+&6-,-9giX%?GE1SK3::IE0+8*eAH5F@O`cY(xJaJaUjo{a~nX_SYhwtr̩|loZ4L`VLFN4D^ThRRFtpx|lȄfhİfpr~njtļvtȨϭ⸆Ʊ蝺Ԍދvc*190QV]tYrMuOMTzhNQhXbVRΒhe~҆§̽w]HJ~Ydp}PW^NK^X\_s~⥘ljatn_T}z@Y(@1[nnҥlsp`WƠm\[FˋqSkWNB`vzĜԔ耈ȸ֍¢Ȭπⴺ􂚒걌٩ë‰蒖Ư̽׀Б՝Тր݀退̰Eů¸Ė𖐀഼x``PpdFdFJLTj:2Jj0jvp4(&&7hEPDX?9`l?2<3! >jz||qtۀ퀀ҀЬ‚އڸ\,vUZ"RCz{ʧýي̻֪ĨÂঀꘟ’ǡǕŻͅːȇձ܀ϗ7ɭꒁ오뚈檤ΔԒ~~~~}~~ ~~~~~ +~~~~~~~~~  ~~ +~."*J,.<$26$"(*<>*,*6:62L,2:2 +7<.0::46:FT\^vr`\7@Z=/31++6((#T1+&+MC7 A@1E6]csM'`CR˔s^uHR:0D\DPPE`BNT[Yl}]FpxnFV`VXoX\hRtl\ذ∌¢ȍ޹դ֘|ꞓ`xEF:@09ZǕpYEYc1@XCC:LBUd914=,%? vzp:nXSnhĜؠāµľ~v*rpqɾwxhq]L_GYoD`8dnvx^n¬x\S2mrWohvZ0PTWmnr|䣶ԴʲltnÚ`_iVxU)TU&GH*Z.;\Zy~zsqqk]ulb\a]x}r^hNP|a:4*(*,..240.,*<\:8D$@`XX8DH<88@408<<>As]OPUO:LP2R.*,8*F(,6:D<:L>,V 2 6"*D $B20&86*BB*&$08>@2h RH+,.+#5DJeZ2)43HJ09.@B@:6:FVZ^tp`\9B^C8>>=>K=<8g.,:0),MC8#HI:L(=cixS+aBJĕ~cTeO[px^gjibq`Fpnd4FJ@`@iLH\HXZ``@`xXxlljnfjxxfx`hh~ໞ߰Ҫҥa{JLBL?Mίݪm^uYqecvg]cXE[E;T@Dr8fSK^@~Ҽŷ@{|ĩ{zjqdQfObz` +pH|t̨́қh^FzºnznpHhj`tuxеԶr|vͦfh}jj@lo\`W4c6Eri{|~zlhy[V]řbqVgXhb榤؀􂐜ξկڼؼ܄ļƴ̮ƾȾƺ̲΢𞋔ݲʽߟÎھހ ᶗ􊘞ЋȠĤxpRHB@>RN:(`e4?$*@*I#Fls޶rmot؟րπò̴द掮vOONzx[HX­րԪü۠孾߼Ѿ칫฼ԅΘy֛ŬxthyՀӦ̙l]aYoңʨ޹ޮЭʭ ~}~~}}}~ +~~~~~~~~~~~~~~ ~~~~~ ,(" ,F.(H 4<( \: +$H/ !4,!.$7J :.<6<:>DJXZfJTJX85C)B223.*7!% 4'4v:=@4X;gJ1lL.>28^ONg{ఈՔupM&&/0Vz}th~wv]bXDhsyZDK:2-CBDzشrq䅐iƄLC^e~h}xԔHGkB@.gidzꌔˊ褊njhU=6~N{]Q(I$~XYHC(@R@w|{ҏi7aJJj46aygDDGs~}tی><60,.244&8<(@ZB 8 P(@Phh@HL@44@88@<8@TD@<@LPH@<<@D@DPTH,2.*,026 ,8^gS;*&<6.4&F=Zh^J@R?=KEW96L:2>D&2*>."6:6B000>$>N266*,(P,.> 2(0 ^B: ^G/*%"#(E>^FR?G&6&EW44L>D:<6Hf6WD4ABad]tnosϽrrwTbMcVERK^ouiGwP^z``HF6LDTBTZZ^HhDX\p`ttthTȐjrdnrnljdlhhlbxv~΂ג𘞉xl0芮裢tc_gFsweXfZKPhiZyZcElDN7>\LH`plmh`}ՓojH#)68nւvjxu[`XDmxcMTD<7PQ.*Zpr䅒nВVNkw|ДVRuKE/hig~ɊttvdMGWm`4V0geSL1G\L}ڀڔn?iUWl46hƒsfHJQڊ̄Ј舜􎜤˷̬ľҞβ°ƦƞҰ¢ּéϹQ܅ɭöɩӿŀ݀޺ڲꄀϐܴКะਔ^d`tFtH\>F>><084.. 6@.B*6"L@' 'X5"L*y.-!AOOJ$O&8OSԨuJ}R8BJf޿ᄀǀ֠ڠĬtt<$p(;^ZwܵĮ֥㭰젌Dz¸о޾{ܚܸŢzIJ̚whணή݀缹ƕjukfh|򱳨πؕóڂ۷ˬ☘~}~}}}~~~ +~~~~~~~~ +~~~~| ~~ +~ E  (*@ H(H<8hXHP(,,4@,$(04,8P8LTH((2L0eC/8<M3C'7(2-D9>dPYjEP3>>dkDPZXXbDHfb@X^Dh\RV\Ќ؈訔䌠츢ЀԐϿ؈z~ȚYK\W.;0\KWKHRDqO7;@@m)M59N@:UxNlvvذЫjB'?<08A@ZW7tz;RrvYtupZPts\1ga7nTjl.660(&,4,B2@T. 0L(L@0`P@@,448D0,084,8P8HXL44LhDD<84@HX,262.6:@H8PS<>,4.<>:4"&'L`VSE7FG:A4:BP6J>L0.4*,448:.4L66%?G6G`b"4"p~v΄|zhTy_Za^evP\^fƓȺpjn[YdkvL:lB}jFHFbc΢t@X|_~zehڂr]4mlE~bvrʴΤȔ耐ȸ􂔤ʼ́Ⱦֺªרٽ򀑄ĭִހȀѾ뀀ɣȬĺش~zt̤ĜVx\tNDHlF\,hPBPXB<4D8(Ow4^52?GA`h*8lV[@8)GOKB'6aQ09 ^vndFvIuBN$,nP짶䆀LjĮ誈qvuDNbTlN⭐ŻЭ܂ԆՁ‚ܘ򠓛ڷבv؂Ȧذڏ̺r{ȴġۙλƼ԰lqi̟łŮ民Ŏê~}~~}~~~~~~~~~~~~~~~~~~~~~~~~~~~ +~~~~2 .B 0 $(84HH`Ph44040$,,((08@DL(TH8D@\8($&,*.*F-" +,"1#$($..:&00BL@BTPB4HFHTPBBPLPTzȌ؄ȮȊ䧗޴ƍ椐Ԫʨދݿ܍d`̖кhpvP4EK:ASNnG`Ly|[=*7@BVE>RA3GEE4ZZLdz̬Ў؀zg8-eX*F;^jg䃛w`=F2B5/DN:#'#62&$Ej'&@igthVVug̲uYXVLPY^kyunymh\l`sPQ^~dwm{jlvcLieK`q\ZT-HKJxhZȼqld}c\`ymyεVTuR[oYQ{ ( L*(,.$48*BX4P,$(0,@@XH`888<4(08@DH$XHD TH\AG4B>::882.,20,,0:DL><>BFB802.8F<((: ,84&"&4:BB&^H0/-65F=8SE:0"2.28D&8B464BH:["1)8K(%&*WXusgX^MKZr~deNh\el~TwhgtBFhW|nHRqmllITHJ34?AjVJB6<:>DB|tt|BHllh|nlrfphrİhnx|ƗzȬʏ覔ڰЮ摜ʫ֖ƴgQmzkZmh\uapgQWNPn]TcN=MHF3XXF^vzjvpvtlna5,g^4P?`nkꇝxb;E/@51R`T151C@:9X|8>4RȺpk~n\b|nڼvVSRLV`rvԁ|szqzhvfyWYĶdxcy[rtwt[{sVhv][Z4NSZl\ܾvxoorvxΓβVX|Vdz^\~Ժ̴쨌ĠȬЂܺȿĸº¾¸ʻë؏ȵǨޛ̀޼θ܀݀耀׀ùƶ~nhfVp^tRTpNlLplf:8x8BNB:pfXMHcBSD@=.DDEPVh(d><.$>/-Tp[slT;:AJ]l`VVx68RJZDtʀͺФȌĸNXJ`HPT깠֧Ԅşϰℕ򊞬씖ΒȶŅԤѝεւڱܤŠҨڮl`_yWw䜹ŕм˜Ӏ򣍫~}~~~~~~ ~~~~~~~~~ ~~~~~~~~~}~~~~~~~ + +~~ G, *HLD (H08<``pp<4,(,0(($((08@D,X<((D.8LdP88&,02$. + +%(    #$(..00,*><:4,&$$((020.2( "(..,6.:>&:&"Y+ #%F!1!0FJ6,8*7?Z0P4b:J6F<, 5&##%('0=B;]#3.'6wkf52^pDAB=a;:*.)98#)011CPF&1' <8>DNM9*.L>4Bh^jxtpn||nvԎиԞdrzV$&Z^<8h(0P(08XXhh880044,,(0008@D(T<(4L6 8DdPDH00(&(2>BDF2,L02J(264,MUHA6<@=3,17/014?A,>8666440.BB@>60*&4226:<:880(*0220:.438pF., 6+7Y64K>0I6*NJ48.H6pQgKo}bjcjmw|INqqA@tejsY|{JLDTqRdmWTUvBkC2<.&JFFJ>B@0&0:FZ\dpBHFxhp|dhl|hltpȬv~nhth~tpjhdTvj̲̀ȰޢƜأ𐞼ܘllalilf`b\XqBgQMjfM=-9UE;IrfpvtlzztblȄnȖ^nvW>VYD*FHHܐ򆠯ymJ6T?M:&*E0RP21E0>5DbUad4(hlvrFXl\KYd}~[S|gj:~q~Pd(npPlPVZQrCsblyyĖ]YrperBmXp4xXΆڐĬzaVePWg߄keg]^Zwb~؈Ҷ옔耈Ԁ䀸؆萿˽ûþ¶¼nƸ֋ֱ־ʼސށ磢خڀ񀀀ċƺͧ܀߄Ӏ໧ܸntlhlZJH8HNhZHD@L RTn(tn .V,, +.&'1#+'&8T>J8FFN$.2R,.V4Z2($02RK8-S-&[iDP([[Ng`ZZQoTSPRkyaXIDe2(PIJ/B)J:D&;%'90&"(! )024>B88><22dpLRJ|𘠴슈ЪТĴĐܐԲyz}،ԴxDrLZJCFP[\cy?h,@*Q2.N,D,1)(*(:(N&T0HRDT^촤DPbH<37¶l{jXh6/6?87,21.2-.2#+0EHA/nVZlQ3(0);=BKRRqFj]xbBdTvJHSZKePF(:`Ȕqqzo|`JfsvϯnjttvXZs`p؃vyʇjuXijidskllia}rx`t04.,62"RA@$D8@4HH0<8hh@@4P ,40(,048<@@DLTD`@@:3/-,)(,/:.D8.<44668866*,06@@<6::64024:440*282&"L08<&22!UG6#-=44@5?<;-R^<8(FRT*03P&*N,R6@/81((7:]YF$"&@e@7ltKX.`_Of_\^T{]Z_`zjaV0]}LDspvLIzamv?o@LWEztjBN^MD235&>@DHH<8>8.6pPL>hht|tldldphhXtd`hptdĜ|xpn\|`Xx~̬|蠘Ƶ޼Ɠ􈊗tlʀiuZSWbnowV>bM?sC>k:[A0D;<;1H4\6.d&>T`Vjv贤JXgMC9;ʇǑr~l|J\4/6?57,47@J*#<:A;*DF.TUM+B\@?J3CSTW_eeX|il\~>bU~(\`ai\u\R3X2zw~rdUԧߺΖ||܃`ZoZh؆Ȱҁ܏ևƦly[komproywrj~~d~Ƥ􊼸茄耔˻ƪʾȾ¾¼􁇽Ϳ³Ⱒԃꏊɭ㸩޳ۀǿŀـȾFƼ삊ļxdlllxxLxpfDX8@dJHl@4880,0V$J< + ,^2DKRnL0/*&Fg02B:I\U`((bTدγư√Ȝԣ₌ؿͼňȌސuؠþ␶vfsyǘꐘƨq>|\tNr\x`퀨§ͱ̀ʛ뀀ѯ~~~}~~}}~~~}}~~~}~ +~~~}~~~ ~}~~}~~~~}~} +~ ~~~}~~~ ~~$$630(H`4`X4@D,D.DPV`^bX6082MpO4H7C^dC<@H:n_dUerYY^rYTiUUSlcdvk*)X@;I15:[6F>74AC; /+#/,5"#&/  0>F@>FD2BD@BlXh`TllxxtȤܔ؀츄ШذЌ輬̠螮tβƛԮЄtxvW"=_bW]kK0696971C8QIGP ^.;8C?GFNN5/94&$ 2H:LxX~rDFj;T^RpzcJV(Hj@C67;T2L?N9.N+,0 *?G&PS>NOPMGgzfhxnpІƒ܌Ҫt\xvٜo[@y:qs`is20B$.20?;8<@Tp8PH,8@4X<<4$ ,440488<<<8\<,T<4\@>:206<8>>4,.26<**F84+Mac=.&,,FA.8AEG3Y6>DH@B@,D(>KNXVZP.,..JnR:OBQmuTEP#VG{fjYitYV[oYUlZ\Zsqk}=>lSTmY[LmHZUh8Oxv9QJ;GDUdGnATA_a4%.!&BNVJHJF4>B>B|hpZHlTTdpxTlTxx\|h`dh||jxvttdnrtnؐԬʴչ֣ܰĤp~k9W{jpb]L^Z^\Wj^wmhp>5~1BLHSPYYbcKDObBXXD@FXFTh̊~XJo?T\Lq}lR`0Jf<<139R0H=H92\3*,7?*37@PW7ag'p8~c)aeaVc>MBXS^Yjxt\zVTUtEGaS42:pvhrapyڐwz}̰jetjlؐМ줋թr`|¸zי~~gKCwjm}ԎВܼϻ¾Ļ͙¾ƾcЂڴô¢ʼƸܛփ揱ٽ؀ʖï˱ʀ􀀀怀ʼnïօg踀ǀրޮʸԘذļԔxPt@RnP<\4VlZl8D@H@B`HLF(T2$$ 04b:&7-0ZYZDkxZS0_7@ZH(4,*D@L,$xiڜꀀ〺¯嗲ºƚ̎И롦蕁ΪМΩځϨæЖܼמ؟֌Ħː{dZuq}hʸ҆lʴ-@y\(zefܷȻڬ֗Ưώ ~~~}~}~}}~}~~~~~~ ~~~~~~~~~~~~~~~ +~~~~~~~~~~~~b* :< $0\\P@0(,4808(,0((48000448444@lH,@0D<4*4 +'!$FD  + +(&(*,4,""&$ "(.,*.4<@80,(*6FF0V6'&(H(6+%(875758H.4HR]wiCzQyznU[sZen_}th}~XN<=?;Y;52>!-,?A8D.&!0 %,L5$4 *$20:@66FPFHD`U<4*,1@84B\PH`icjCL@:(0F,%5b>jDLPFDD>-+>@,!(&)2%&N8@Y-/;gQ\DO]SOf>a.SfljzxfRwqi@>DC7H\xjxxFqt{вvƢ~tj̆|yƜ䖒Ú~ksqijengvUqrWnm>:4L,VI,(4(4`XH84,08<4H040((48044888400koKGO9$',552-,-+)/0)5J<,266888:62.0f*4D89>_=1K>8#7FA=><@R7=XT2N"Wy_tHgsMQxvkV_x_o{f{odJgpSgKJqXgf[[ipCS9^9RB|HFhT^8%;3JHNN>HB@/7L1)5\:dBLPD<>6&&>H*4'+58<'F7;dOYtHIT%,z`kLWe[\oGlJctzr{iV|wrRJRT/,?VpxLv{̢ީ~Эxwʤ䉻tclojphoib~|_vu쏬贤Ȍм؀솊􋦿ŶվȄĬ歮éŞ֦򂔲ѳظл߫֨ԀԀЀ뀀Ӏָ֫ܬ̴Мltl\t|x\Xpl,8Pl`8XLLd:4<8ZZ64(<P0*4(. B~|$.d58.U h[\Nl4H?($k\x X4*/-KHiKTظˢĹ׶ШȼĚγԔޖ鯨ހ̖֬젳̞֡Ƒ躀sF<@eJc`Vjrfqz|zdxlbtmrм͟͹֧ϻۆ~}~}~~~~~  ~~~~ ~~~~~~~ ~~ ~~~~}~}~ *LC,, Xh<40,08, (0,0840,0,,,8xT,$:08*" 00  +"*/ +  +!$&$" $$&$$(.20,(288428tf^|NBRRXrrLV4HNDK3qTHOqRFzjsfY\_Z3lYO[?YmiVE[iW~`zST9GHR?YJ*'#!1"7.(24GA;E#KE"6B22<<DF:hxhpd|pptȴ輬̸̼иР螰؞侃xΪꝿvxt~~ĮvͦN@F%C2M}QLDL;IIA753ERK8G41TA7!.5P@Xr@d`f[kP422.2$.*LCj0TX(jG)H-(3PTTQD(oSSI3"x,&bHDJ-BQF$"@"jplhfVRzrʴ\r{}ˉv|榦Ňh䐐~|B>=[415^JRyMkGWfwe"8LnR (4,<$0T48404<4$(00,0840,((4tT,,>6D0(0"*(.("& :OP@18,,1=PB @.0246448<:86:@@>:4:>@::<@H86DRRLNTd*,U6%ML4HE!CCVT(61%>28G*BpV@T*n4B@D^\DF&>F>I/jM{HRyWLu}j[cgi?s^S^B[pm[M_w`wllXHy{n|EIHC@NeRd[U?ZGoUOnLrf,(8FVHDH@><688,XptdlXh|l`X\lldtlptx`Txhrt|ȄnĴ҄޸ɜ¬̼оЭpWbEdT^daZvObta~`acFFXcd[QMIb?2Cu|kJRziem^64=>\DZĐl4TUYRgP89<7;+3-NB|h0TRX= ?('7$D661A:*=DNQPadOwO{]^H]b_[M0vW\VB"@D>tQNR2JVL-."&L*xwtlbPN|u̒[܄ԓʖΐd줴؈򖔎|txBGEb;7:eW_VtN\l}l ȬଌȌ膂슨ǻĶ̈ڵը̮̚Cܿ耀߀Ѭ˼ٌˀ€ހ退٘Ķĸx|Xp|h4pdT@`Td\d`D\L4B.@H(4BT F,B(@=4,(Hd vL4X4Bf:64:<>>BN^3ZJ*>G%0)-&0":A0:4-566DZltb@DZNnP|?;N@2q2\V7R=ChFdhZ]le`Z#GKvNo_]]oYqVV\mMV`>QS.!(&U:B>43*3$!!9M$>5WA!2DH@.4>B@@:D>hltlt|l̬ԬĔİЬܴĀ쨢蜴膩xڴzz{~잗䚔}␨hൔqUUMnqmUldlKQ+%6(-[KUmiEOADI/.W/J0*"- ))(L  .^vp~lZsPabZ6/=0/7@QLVJ62>@B>:88<<<@L\2XD(E,&^@L38??4=P~equUxrwU^6((0.064T\x|xpd\\h`Phtphp|x`x|j|nČ|Htvlx腩첄ČЃذƔyzrh^vPL`V]grcz~LAMOGNphalLUgcAyF2!!8^rXdtdVHpQhmfC:H:4:APJPF6@HVS;!Cs.B@CH674M>6@QF[Gcq7;MApVMda=Q9[\6UacOO>-<) @.hAD6:d"TF|HJL@:NBBR։䆭ظ􎂲жy܇|ХijzzȄؒTZl[p\A/9S/5nQWreMNĞ΃̌ДӿƯę򀋇Ծǻ±Τʾڐ葌Ԝ¡Ȁπ؀ּÿɀ鸮ӥ܀μĴx||dt`lX PDhDtDTF,F8`$N ".FL6&L8hpOu>d*.>)0J +>19*6@X0=n +.?&U5:O8dUzzůԠΛʵƳ͎ȻȶĹϿʻʨկҀ셂ݲ򜠨IJԜVltv]sUx\L_{VH<|҆ƼtydĜ@4zHLJzDRdؐĖ໧ޯšͩ~~~~~~~~~}~~~~}}~~}~~~~~~~~~*%J,<,HXX@DPJ8:P/,4D"-7*" -+)$6:'>8>)NtvfHN^X>XTJ4@hNGHMcypPdQv[jaz<^f^OYFZQEH{SuVukDX>QXF6/&K,3%./, )&/)0.5'-!0@@L@02604DHBJB:8h\:hhlpxt\|Аศ̨ȰȈ،؆԰ȰԼĔжꁚzĒܸҍxv~Ȅ|UV^VM?cI>$,ZPhTD*X\]xODQULCK6-:.N8!+CG-0B.7& $-)14CL^;e_SN50WK"E/T*2LNDzPp=L60!/?APEJX{N 5h(\(X@PPH$<(8D88@D<484((00,480008@DH402(" &*F&,3_]M<432/.),)3]G=WXUMHJLNRRQOKMMD7/*?F(@*408<<::@F:>@B>>@@224246<>>F@.0H+(3G+;J'81-B9#:883CD.A:?(Hvb`lB`08FnxB*JMF4AlQJF{Hc}xUq^`iwXx}iYbPo\[^]yY~xL1oLajXJE={DK>CIMmW`faMgLS)NG%K=D)7+FJCjMmc]H' DFDT@tXhb\bzVyISG01dXlR>NPToJDSYPDM>5C;aPA>BZPTjZDx򨣏ؑƢҁvЪ𸤖|wrjxhT0D8DHJZFA1>LJVHL[䁼Ԃ􈄤𤤌ξĴEȬͫȽDz±Ԍ躨Қϼـ֡ۄ񀮱ӿހ㇀ʀݮʽʾļАШptpxl\LDxh`\D`ThT\ N@p\66^@$6(6H4$,Z.rb:XezDOWH48&aZB>J~=?Med ,,,3-fW$jP6$FO~bԒǀ܆ 򄀀ڀŘ憪ðĨȦȽּιح̴բȞʤ̖ªҶ䊮ehpV}ssnYWpnOx`(jxgYƔֈȠغ~֤Ȉ֦Ԥxbf`\VEJYdL|rԁõ}~~}~~ ~~~}~}~ ~~~~~} ~~~~~~~~~~ ~~~~~~~~08@ 848H0,@@4<4@@4<(L((H84T, 04@4x@48.+ +4"(!-* $**2:.,.(284$F*22.+>@D\LT4F*A')D .+ '$/!"%";3?.6`h\P|ldx`舼Шؼİ蒤δЈظȨԄ|Ɛwzvʚ|諙}xވ֫fg>A%54^J~lxZntpnzֵ扞کzL讐ƦĜβ~Ԓ|ȔĊДƊč|@p^VN7FC8EI@]_UXh⽘0FT (88@PX8DH,<@ TpHD,P,(H0,D,4D84`,,H (2<*29WUE-0;-%* &3--aD?KPC*$!%#176KMI=NPAAA42L4@B482<*8<@:.<@LT<6`h\L|l\p|tPpll|ddhn\xĬv~h\pĬℨp蜌֙ʃǓԆ̒ƌ򹞖y_jVeHNxiw_v^ps`]j_eRgUUc~iM\Zb;ZmHC#FbRD|tlp2pc}dFGnZF^AvIPpLLL7>JIF>Y7EQ6FTWraS\4_Ta[J8EVLQS[E7D$=DkWEYcB7&.?D44@D(L8lZfF\dbj澙ܭ¾ʬبҴ|Ă~d|予̬~ޮ~l(j^dfGXVJQPE^`UWiŸȰ耤̌𔠜ȤȬѰȼȶ]ʹ׸ǽʶ̵Ȱŀñι쀀΀΀Ā؀⹵캸ԼȌxhht\XhVXP`h(|8xT.B4!+88DN>,J36"=:8/6( .#'0/*"-:@&DNV`vhXzVxX`HH2njYgRG,;+P015LB`[Uf^JOHny^FQhCoVXHkJtPUE97R0,*9 $) 7(E<8B1!!$""'":<'0!!Fdnj`R00F<(>66<:tpXTlphhtpdp\̨ĬȘ䄊ܸܢ윆ܜВܘȸЈ䨐̸Вwz߰y޴|ypǼĘ{ԪXy$vZ:[me|KPBLV?73YiIHl@j]PSH)#3@8K;;WP.GFcO>&$66N:6:8T*8:L:wxk{gX:C2Z?EMf_{h_viRVQwb_RefUUX_QTGxZC[NnS4XHeaVzFyPJcV8<;YOA84':;(5#,+Phd^^T24F>*@::<:tp\Xlp``lhXhLtltfnx̀zĄpİhxȸ|̄tऐ䞛ưʰ纂ȃīҰԬ܂xhLbj\rOJ]\dpchtZYRb\n|z=M6:m@LiV2HB^TVVV|PLW\@l[jhQFLKEHj2EPK^:g=G7^M4:?9W*0KIjk86:sh'Fj6.RbP4@X>\46b^FĽڊ꺇䆔Єʂ^rf|讀Ȩ`҆l\~jP\k)6Ltm2bU݀݀Ä:Öڴ،Ԩܺ¶υϹϵ٫ڄ✊®ήФؠbgsT\FVm>eOaTj4(VF_]ceiNhyydܺȀԍt|dmj@kfP`̔옆ʭ䋦~}~~}~}~~~~ ~}~~~~ +}~~~~~~}~~~~~}~}~~~~~}~}~~~~~ ~~~~~4,LTt`H8Xh0X`XP\4 HD<`<@ ,0H@<(8)46# CS0'3-"#!&  9(;$ !+#+""-E:3, +24,&B.L@}F.DT.J:(:BB:%"@%6* 6/.;WYH7!)385'PVphnfh>XpHp[kh?]V<:L3"H2?;04`:Uh_ERZkip}VfVuZRyFRH_iCge\ed08(%7>'0'+-E"DTc77- ": 508*74@D2JR<<:DB46:6L4`PdtdPR|||tlĘԨІԴ܄ĸȰ̈~ҫ䐰ĢƎ||x8D&TQLLkIHbGL<^Z@FRA;[@Faua98%+M12&@187#g18%5*J_XMfiVsxDU@NZn:7)+8,3'8@NT:HF,6:DB6284H4`L`p`PRxlpph\|txpl`lppplttplxxHjмtЄ|ЌxܬЎ·ڳ򖶓Ъؕéࠔ\vxrkgk]zkn\eTL|PV~JNtuSWbFxUUR\I\~gn970NfNvbX̼V22tVEA?+o)>KAUFdHU:0Hr5>JB@ PP(F6M4mD(J,\HQ$U>H)@&"E4Pr-;J`Q"KDЄ倀怀ۀр䚠જÀ잛Ūۀ؀¾ȴ؊ƁފДa|`*`vtybovI@/s_\?O{]q|v|̼̤ؐ쎄аXvNz`fx܀̜Шڶذѝ~~}}~~}}}|~}~~}~~ ~~~}~ ~}~~~~~~~~~}~~~}~~~~~}~~~~~~}~~}}~~~~~~ ~ +~}~~~~~~~~~>~k<6-(2*#%/(&!0(/8<-5.0:HO'$+?9HZXXnp]FLFN8dxeMnkBy0C>6SHK--:^FEGN{pz|UT{EFIh\r@[RJM|TSa^^G(#9=><&11?JER8jO@4D-,)4Z`TB8LH6BP@J:>>8FL4lHx`|xxh|xȨи؀Ĵ쨔ج؄࠼جܼŪ֔ڜ{x}€~TdQRZeY/}iXPMOo=h?E^JkMbMF>]V53AaY?CL;M[R~|PH`lRFXTTdRv~ꇖ̀ܥ򾊤~jlT||lȚ耶ԮИf^VfZ8tnœڬ◒D@f: \pXD@Pt@\ph,H$08$#(1kF_KND=>DID,&4gH'"4*6'( "2+*!-1L5+XHZ6RBKG/:60.$882:oA"D40B0)+6%+,>A19.1-E1LALL@:LXWBNHH,\~xgQxxTD#X[Fthl>=[~da[W{\dQRWl^ybWV^bsxYL;gihe;`cr|rwUcVRjZV.5-/4NPJDBVT<>@.B6662@F0X@hPp|dl`llptxTtȬlpjx\|p\tİ\dll\ĨhzԴʰȊږमîؒЀڈįXsuv~tL|vsuOORyfYzYSMaJYhpvQbvspjMIt`NFI*9n]?6ZcP& +Zpp찹䀀̀ﺧȞȈڀԠſӲƥԸ€؜Ԃޤj<@|XR\IjSJppFNdef>TjqhvjP\t`lN ج|Đڨzht|ܘ~}~}~}~ ~~~~~}~}~~}~~~~}~~ ~~~~|~ +~}~~~~~~~~~~~~~~~~~}~ ~~~B|hxHllhX|T@0: *5,%% 26.'!-% "&6:G? $32# RICG<&%9?8>;=Na`9 %211-&!,"35&*-%1651?Z)$15LPENlf(@V2\wvTB<:ARNUj=;.#(NTV>*XN46:@HDNT@.DHHBHt`xt`xXHX``pt`ldhlXjthjTtxplrxddhfl|txxĤʌʻʀ𜀴쬠ĀАΞsZSnp\]A-E=zfWL]yqX]mYbhqc[^S}vRdkRKTW>?2D0\~Y]d]MGZP{92.3uCQED8PyEKOGllcILPkWrSgvPu?Bj=@OrJEZSG.C@'NGK8CSnwLBfdFbpXn&f4RZtJlz惡ƴ溨䦉|丢^LHHBhp||`̂lrlxؠxzzhNੁԀؔp̜аڒˀü˃䀮ТFʿˀܮҺڶ⇃߀̀η䄀򀀀Ѐ܀߀Ű֨⾶܊԰ԴdphdhHtXpPDX\$84, NdXt,:0NH40P4c | P8BP:Ap.56{BW^nH@F6< 4 V`pP<7+(drBڀ£Ȫ؇͉뮦θ̱€Ǻാʂh_nvbZD4|[^W$J`In{]Zll܆jrz؜Ќ|UezrP`8^rv~}}~~}~~ ~~}~~~~ ~~~}~~~~ ~~~~~ ~ ~~~}}|~~~}}|~~ ~~~~|~~~~~ ~~~ ~~~~~~TPL܌pt`pztT\42)@B-" 52,) &%$3#,),,+-#? $) 7"2' 236/0#*-8/1M2) 56O6|V:|A1.H.*QU .>,1((-.@<#)B8>ZhRp@TDxxldhxxxԬ𤠴ܼجȠȠ䬰Ј临쐎ܬĚȜxzxҍ܀Њ̊pu~n2 ?`B`9hk6>E\v>rDP_rr[YI",1B3-U\D~FY[fDHGPB+p^;9=07C$+4ILP:3HS3J/D9TCbEE^B2+,/.HM;BatVD. H)$?6STB2,DX;L`p`\Xp\hj`pf~莤̎֨~Ƣ䀞v|PpblJ\Vz^pذĒ~溔x|j{@@$`h``XPXXDhrPT\XtptdPdD</HN;!6/+&!!MA=M/369<8.4D7BBDD@@6U768"=#&"5(,+.,1'14?57T,@*6*:6P9~R*l> /+H.(PU$5K@3S>FB/FEVP8,A[8KBOO>A[n:)=H<:dclSryKLYQQOeCOFO\Qj`\M]dUV{iQmT`U~e@M>/:?K"PJJ5Q,(?9<5'N0HXT:2b>42*.,2N\DT4H8p\\pptXPTdh\XdxlptpxptlxxdĀtd\Hxdp̠|̄ڪ‰𑆬֖ԒbaP'#005NP0FSrStMWaWlNjx|VSCLku`>L^lXTPhTX^HTXlXjĄ̨t~vtt8hV\>TLrVfvn|p̠̄tpXdpx젌Ęvlؼتˀ׶ſǿƘƽ⼵⮷̀՜ڹ祂ЅΩ瀀̟̀ܦҼ߀ƺק̾¼Ğ촨بؘt\0TLpphPDpH`lt`|T@x莬̸ܚڮpf@dn;jZbPR\~}~}~~~~   ~~}~}~~ ~~} }~~~}~~}| ~~~~~~~}~}}}~~~~~~~~~~~ ~~^plĴМȼĠ`vD<0 %! L/02,(($"*0*"/+)-12!C+54!"!'&)4E%8!/5,6X=le! S4S|fX]d=:F6VpT0o9?Z +), +=E P[;iD.1CE3.ZR9#W'&L%EvV1>x<$L.0=*t!A;?FnLFICL[wG80K>/+G<2IVA7@iFTtCfn/6u4N4!1OKB<'0?C]CZa'%&549OG6.=1.:=\VRhjP\|fpІj~ꃬ֘ȶ؀xVrF|X@TvlvذЮ~z|vzzܒlΎćTl80tXhHhHHP\|\z\&C:,0,)-,%!j?O77NHC>6,3840A??BFG3U*8?; %& %)$"*<%<&59-4T;kg &,Q1Pn^PU^5.:0JiP&p@Gq&HG%DTY!ZdM}YDHPR1O4/Dl_BJsd|4DXOVBV=Ek$X?sFHpFKfmh^o|phwkb^NuFCdgjzofSJJ]UI^KS?>839WZaB%7?D@FA,J26H>>@L*(.F88R\dllpPhhhlph`Xxhxtlx\ldhdĠА\p\|pLhx`hXdt`ĸܸԒ༼Ƣ`Ȭ܂ĔРЭؿ^Sb=JF6`:,HI70VUNW]VgPbqUZWwWI$5a~riZOW[gP|AHDaM=Rb_Va9C44J`k[U\NKTGHrcZQ;rCZWvZZeGLL`C>Y^&$(:8>UL>6F66AB^SMefJ~T|dx~LJpTv`v|pĜԗƲ|dNph:l|H4Fl`vjd`^|\b|n~ĸdzhtlǰ̜̰Ь䆨ȸøŽܩ셃錕ƍبƀܴOܚ̼Ȁýϝ֣Ӻˀ꩷ص酀ـ¾܀ְĴʲ؆ļČ||`phd|\x|h4@L8`lXPLD,PTRn0$DD,PP@@4,P$48{M`#$13*2*k:&XbD8J6"R@_dBZX@T@UW_uXe~̎ؓڕĀՀ퀀Բـڀ退ߦӀŷ󱖃ܐМlRHm|xZ|܆ʆΚĂܼظ̄􊰘䘺ԒʨLQtHX\CM?&6~~~}~}~~}~~~  +~ ~~~}~}~~}~}~ +}}~}~~~~}~~}~~~|~~}|} ~~}~ ~~~~~ ~~~~~~~~~~~~ ~~~~~~hhzx|hhPఘX|T^"*@@%". 1:(1#,"!!""$'-1:2:Q6.'%'3* $%2=6XE@^4j(5BBDX\qx,VnmSbVa7@*re=E_'G9 +33(<2jaztTYRK:LJBM<43E5c@HRDD.60.)D;.11< IB?-2/m{~d`bVXI[&B9/;$-?9RR2@8R5G99@\ +Ls!%FPP:4.$**FLdBB:6&<4"JT4`X28Hh0P`|dLxPh\TTdؘĔČРȠ䜠쨀جܼѲؐ쟠Ġʈy誾|֎؜ĐduTH:T2ZP5H%H#q?K:[CVHP6FJP+*(@&5E776KM*.A83._^JScG21BONrADJ*-F8HNhDpdjVbt\jܨh`Ć䀐jh^b|\hZt|hpl~~ztpЌ䔰|ttꀭ츸ހ(TH,\@'>Q80 -!#&$*%$/.:A5R=0L&f"4B@0OLghHiiR^P]38la7Db3[Q%1KHgDLPJR@>2,*ID?G5N^BngePRIgaaT[Pm<^Z/ERWFXMvwZVQRsQlm*//21c#/)+JNN@@82660DH\6686*@6"DN.ZR,4D`(@PpX@lHXTHHXptllxl|p`ppdl|dldt|ppPXnld0ltȌdt􄫶̨tܙ@pxtȦܑؼڀ얨⠾̖ͳĀȪbR>Z6f^jdJR`D//AMMqCFN14O$APUd>h`hP^d@}lLddP`Nx~dp悘fbPTnLTJHTT\Zn|pzvbl\\dXxlj~lblĀltnjPxlp}䰴ԀତԀȨ謘ȼԼ²ưӱɿڷ򬣬۪ΊάƀհտȴįˀҾ蔾˸ߝݴۮ̀ˀԀʾֲC̴쨴Ȅtd``LTXtTDL$(: +]YGC&RHxZ]hXT 4 >>H3`"8&!) +@TI>L">=NH4bڢꄹ؀Ӏ߀ìŀ䵸 退݀ߨ̈́ ܄ǰת̿鹆޾ȼ܆zL^̰Ђvp螠и욘ИԼ4PPl8`PkCFP~}~~~~}~  ~}~}~}~~~}~~~~}~~+}}~~}~~~~||~~~}~~ ~ ~~~~~~~~~~~~~~}~~}}~~~~~~~~~~~ ~~~~~~~~XhP`\`|~Ȍ\h<6D8F62112* + 6.&$%( +!(/ "+)+55K?,'$,)#(2#(2LI'jPLQFl$dfFܕJTX(DB9zB0b\Qk,E +%$&8/&3cZLYa?4I,(\L+*pĒP$>6B"68,$Q1 2JC-%51PL%W|e?ixdSA,S .6<8EG/2OJ;(F"$>(c 2(J-1+322B@2@D4.6$TRBgM+1CH%)+2;;=KBdsSC\:7LX==CNLG]-!:8/SXB?5+(9G2:'8.*)>I6E<(AH24>!" 4%DL0*$&)- !)&'"!:08%275KA2.+/)(@6#28LER6.G8^VT(zԋCOZ.GF>L2b_Uy@^#;:9KA5AmfR`oMC[FDs`:6{͠\,DDR,<<2+\E%)<$\uoX;^Vo`:-^iEmyg]O?.qEH]irT{xFJ~|UD]As9EM"7H=_1><3962B@8NRB<<&TP::F@86204862F@HdBX\<|l3>(. ?#BQhzZ>ռ:v~NDɿڔpqJ89)&SoUd[Q_N<<@R$0>;-!*A*%1OBOE?Dl.Rb{\[VJ94%<4B%-0DE9N0D."-'G> C H',R*6NNB>0,2.,. *Hf8::42026(D4<..:dFBKLoc6R4O>SJjk9^:$F@+@9D&-::'!-$+#4$5+2HKB@/C= <$ -1C#EB94DTTYOS@SP@?Oe<%*3B^,Z>x֒Ģ̲~讔vlthRp^ԢẍԤnhhvzrLX캾ನʀ\pL`L0\p47D!%-0 1R0IT Z`:ůp&^j& ùՎnmL>B6@bzZk_VeVRULgOy\JBm}1B (PiJIfbVZcAlk]osmZVZacJyGvS~`M?ZjZZDtTdiq}yX\8SL3cBt>BPEKbQ`XVp=NFPL/P-ID;3ANMRHN=UVKK^uM442?_,\@pxĨzbtxur~f`Ldz`T>XDJ~prb\pxjtTRxX~`rb|`|lP*<<0rn$&6h$BL&4Db/g6FZF + lZlTTh,L8JN*ABXL*4goeoY&=d:4T2b&D08:ԓ氺؀ꄀ$ڀڱ»ѵ豸Π޺޴~ƖĔvdpĨԘ༘ԈĜ쌴䐎솢܂ȊԠ¾ĐtT>p@tx~~~}~~}~~~~ ~~~}~}~~}~~~~}~}}}~}~}~~~~~~~~~~~}~~~~~}}~~~{~~~ +~~~~~~~~~~~~~~~~~~~~~~~ +~~~~~~~~~~~hpnx<L`Ptj```@P6K1)$2% ;S>H6,$2*2:>H5H&! $)%'3,5G?%"R!!MQ.@vVlr̝ʫșPc_>2<[LZ@GsjM[QJJODi]p5/ePBG)R:=%3#kJ[uv?jU ,0,$*"' " =S[LjeN7?9T<9%YG, & 9/!+56C=4$@*C;qV"62LXN:d^b6>j:ZPuy0~ˬʘOefPJ+,XwXqVZXumXUZN}oB=q\HL4\FL=N6yWgP|g2@LL@4>-802;<'=ED=BDZquX~tXk]3I/CE7_?wp?HoQxrhUqKUj!U*g)1PJ\fX@l`d:@DNP(>@H&6BF>8:824X4B@`T`\@XTH`\\P\h`Pl|xpx|lljTT\|tlh|Pp|vvhpȰĔxؚȰdn蠄Ф|Xx֧԰ґ֙@dlxDT.J6S[@@bU}>6^R_\Qn\lM}NL_qfhk^JIxxVp\TJ/NICRCtSVjbs;J[#->79?H5G>Q:7GJP6f_L bB`dn~pjn\8\*&+H]W? 20>:>TBP;ZbD@+#-&B4K9x%F'!0?1;GbLY(3N|xǔvԃؘԻ|_c(-?b7FK5>>/^eI+E4bmsD]%HC^-g5?5so/:-ts-0& ' + I)BFNbRBVAFcT`T(41:&^' '$!6A +-E!94,/rh:J@V8>L6@D<:<6&2206<\HPLlFl8(@`4\dH`d쀰䔬Ԩ܄䘤ش𰎨Ҕ怴䀌𨠸Ьȴ|Ưȵ֥Ɓ؛Ш|аx0>41:$<-&4OG555"'2FZc>GI>:AGY<(=0'"6I0.:01- +*/!4+12.K,)))7/ A/8>.VbVplYHb:xVZ9$e:' $ )6H 31)%J$,J6V<2\H:2FdLGOZ2(( +0@@@>FN@0\6`Xlth`XphX\P`\d\dt|аРؔ𔸤̐Ԫ𞶀؆̼ԌЂȨĘ▱蚶{ʔwܴĀh 6. #G8* , (T0FC>&e5/b%NT`KT2iER@&2U1 +49&*H(71.-&2*!(?"/"1&'@J%% (*DH<2 $,!.0 ))/ !,'2[H +NXvФشؐvv^^e6Pn<6Vjl`xL쌆䈜쀖l`p`P|nP\X\dD,dN+I*66&*( ?==21H<*-92FR(X2,:X8 :$(/0=PUjBdR*U-ALcrT0-Zj:34D8UheEmtfPLMkNaG]D;WMAC[24ICfLY>k{mkbZZXbWbVXPSQTbSF489.=/;@/93@LC4KL7&4>9CiUZ^xxp|xȰflxplpjlVT`*Bg.*TjhZt(<0($ ,YX(02*K3XjZ.0*&Z2@LJHJT\fvJWLNT:,hpx~qj~`a?B8!hOJpX`|zf׼ſՌʁgS}r=;9-+K/LH,YKND:3$6K,=A>@mc,84 )2,5 8  0&($.=OXfopbhZAVTIKIR&)1 '!6#9&"+.<2Z6>M0<:FN@nT2v]&L:&6<6D>>DRRJ@<@hlh\XTppp\|pČ܆萜ȨȼČȰ܈֎䐀Ȝ؀Ĩ|اǗԠŢzy٬}Г͐영ؠ| (2DS=V)b*.B +U%=.#'AM99E/QEOSBO3,9;Q;@9/3(885=21,#,X>'? H9HC &/'4&(< -!*+% +*87,&Jb. $\lvİp`vl@2ZjqI]..4Kp>T:JBEO6"Z`jrvfey^cCF:#gONufn~eҤҊ~З}bR|ETVNQuExpS:{ilSXPC4Gp:P$aR]N{A'6QG294C4R(60S&$!/'E@-7CmJ\AHFOV\ntsdn^FZYNXUpF>1OZ3FC=IgUgLA9XRfA)?R;2#24<4b$:>J*6:JXN(xW4v]&N@,8>pX\tt\PpHD```PltPpl`|`xdlhhpT`lv\T\Tpd`nhlĘpv~ltr~~~}Ҁzt|̠l̼߁ޮƕ֢ŤȊ̇ϒ}И|T<;KYErA|C6E]=1WmYF?PWGKYFldqyW{K_PORTTYRJWXN_L@[bEVO=CpZHABSUQDTWea]Fni87\mXUP@H=H5G?0!:99.GTGD]V1F 6>1]qD*\tМ\ht~bZn`>4\nsJ^>lVxxTE$bfHFfx~j|ĢxbZhjtp^Xj pL|\dRPLdnfn`jDP4`Lr>,ѠҴɺĀ΀򀀀ҀȀ̺ǦŽΣ⚐􆌎|̂܂ఊĚ܊켊Ȃ܀ +о~~~~~}}~~}~ }~~~}}~~}~ ~~~~~~}~~~~}}~}~}~~}~~}||}~} +~~~}~  ~~~~~~~~ +~~~}~~~~~~}~~~~~\xxhtȤ̐؊@PK!WfHNM.gU9aN F@@.b# +,-#?X7W9H1 /"$-2J3'./3&)9C:&-6'( ##$& "h^2ZfdxnXttPhLQDP_\\QlZRiOTSLKgAOYXBlLŒؤ|vlthzh̔؜cXfNN40hXT/;*?E0HdT8`@`X`hrtx?UV2/*"$T]6II*cP6`O""F:*>DDHfztP@_@:CE,Mp_p{7R+@%:wbfjzѠѤ݈ꁑҋktTBmEwvS`Ljo`{\tnvI8\AHL27WMF?oJ.c:$%4J--!%$|J2"-DMN.7)=HLdfRcdHINIxxyxZjmiuC9$B?2]\,2_GAPF:=%K* @26*b8j>J*.B 4D*(h0f<0H*$2"4*,:@:8:FptpT\lThRpXXVO/MTwWYyXPqI\XN;^BMOEXRQO=7PM)HYG;Os{LgWSePaq??@CBHe`GJMOWKKYbVEN[RVQQ=5FIH8BH);,7xh>\f`jdH`xf̊@THRJTebdXtbZqW[WLId?KUN8^>vzznʐtPv^L|j`T`Pz[RZFH20h\V1=2CJ5ܸ~ЁأĴȹʂĢ֐⸷ǦԂ耜ʗ٨؂ҐȰՀ߀倀ꀮڀЀƦַ֨׾ҩᴾހŘھְh̴̀||XbrT,x04<\8@PXp|XL0 @PPl(DT4z:.( |P8D4$4B 6 :H 0$ltR>JZ31cz>$Ns[R;bLHiGN2*14IZCP5>F;H46AAQOG<~Zpܒ䈠ڌٖ۫۷€退ۀ뀀ὀϼ瀀쀀ҀڀĠ̲우Ԟսü͠Ҥږܢج଺ھؖ膔ܰ~}~~~  +~~~~}~~~~}~ +}}~}~~~~}~~}~Q~~~}~~~~~~~~~~}~~~~~}~~~~~}~~~{|~~~~~ ~}~  +~~ ~~~~~~~~~~~~ +~~tP\̤LoPD-CC +28@=SK>HmZ@.4|5=Pojoqjzlleu쨮^RLhYE2@@IYŏ\†u]aaR?i_XoESGEM]O*`O@>9:bZZ:GA@[y`/#-:*(B0LLHGLR6B48Z(64,,.,DT:6LD`t@XtHDdP8xHLpltlPLHPd`h|^xTdXpllfPTT`t`d`|ldnlj|d̤|zԀܘ|xzؘƳѣޚ쐢ҔڣҤǭܓ䙙a^s?$*Cn"6doXiXXR?E|XSLcvIK_Y>O@RR;ZZH@g7aOHqGSehJ^TZVXDEuTm:m_mdKMNx>]\\;_iZp_oZccgD^_]6.21E9@+Km6>xzLxhHV:zVnrK@PdfVZ__QN@8?@VJ2>DA-.6S@CX`XxrjtfzPrr|nhvld\bPb*8?6`>LH&>FLCFHԄĤظրdzٍۚ♙ޠɹ߹ץоݐҀՀӀ‬倀׀ɱۧδր娵޷ȸܺڀ՟֐̺~ԸԤĸ̴Дl`TP|t\@|L`P\xx8Hh@8F 0DP0<D P>4JH6X>0 D&00P0F (&MS_lSoB"CB\O(I"LQ,6^=^-EbWB+tEF??b>(I>gMSu5TJO,i4D^L•҂退 ကĀ׀}꿱˃܈”΄ĀȽͳɜ¦Ώޖ¢􌸎Ϊʨ~}~~}~~~  ~~~~~}~~}}~~~~}~}~~}~}~~~~~~~~~~~~~}~ ~~~~~~~~ ~~~~~~ ~~~~~ptPD@HhȈ̄W^qC-)CL=62Jbn*(VMIT9n]iLlmtihy]lpt㺜|HNrn|w{̈bnJvX~JkWubuags]t^Dg_Co\;^Hh^|[`C@JR@,7C;!9;!3^)#&\(74:@E-%0CHVVE@5&!";A"B3:8->(%#"" 49$  5;>"`^P0$FTDlSVR6GXHuO4EJPU82K!=,2J1E+9(36O\hl~tjlhrv^pvؤހhZ>8;D"37:NPT<0d NT|<|@xr|SYl?-/KV0C8*:He(*UKCN2`V`AvL[LUtDek|sJ\dbຜt6Dvnwq˺܍foJw\PhRvnL{q^dp[u^JvrXvWya}qdhIKen]1ES6%/Cl3+'2*n,8DVX>ciugSScme^@sTz`}wKYl\YaxEDTX;2P4V\f5AeelbHal7EI_>R0=%@BD*D>\BhD4*@:F*C426:<>"B4 @&24&(6X8>2hL4p\@P\Th8TT`H`xȐlP~tf|Xt|pl\hXPplȔ\x|`Ĩ`|X\tzH|ltȎШؠ֨ܮށʈ|̬`xt؉ɦΈƨ̵撪엣ӯҰDFO,"+VkE&GZ}DfN[d@vRfu_Vs8DH'9>@BD:HT4DĄΆҷʑ왣ΕƤܝќڀ춎Ԓҡʀ̀麀ϫČЕ뀀ڿҿȞƺȾʖdȬ̼Șؘ記tX~nZpDX`t^Z8@$40tTTLHP@XX`<D$  H@8B,<,6V:<,&< , .sKP\*"$2*|8b<4 fF,>O|U4,BbL^D83"e>6 RGfdQ_|qȼބῢ׀͹ֿ ǀ뀀yҀȿͼ괺²ũѹڴطʮɚ쀒ĆƐԐ⑛¤~}~~~~!~~~}~~~~~}}~~~}~~~~~}~~~}~~~~~}~~}~}~~~~}~}}~}~~~~|~~~~~PxtlDдXTS(.3@BH:N8J7=,"c77.nrRlO~\c]]Zdsʇʯ҆HZ>F:Kt_xř벴lWH8.4H9?j:K]f}`t6SQ.Qh`KS8-K9DMUStZed0YOf+3* +. !#,7X^f98":2VdI@48&"("(.B("2.,L4X@<A<+bOEOS_@96?\JE9$7>L<<6=46NQ%,\KG"l6FuZSjhtd|Dlpd~~njnTиJ{rAJ;E'7U4+9:6>Jr@Ph\0hdlČWR~O$,3CHL@J0@*4*$b41(^`FP>dVJPl|KH0fOfa}ȰڇLZ<>7GuŞ_u󽾛t$ZTD>=RDGl;I\dy^p3SR1Sji[hCCWQUgiL>\tPtZyjxiDm]I2&=I(kC3(>F(GkM8>WyQgbe=EM?``seLPc_Y`lnHql9K8/Kj_NL4(9?cPOXDJD$)7>^bf98*$<4XhKJ>B0",2$$*2F($40*H2P<88XxP4Ph`LPLxh|dT̈dxlxhbf|P`@|dpT|||ľvxphtl|vt̨̄xpd|Čⲝв@p`v`tʿ̔줢䍜ԥ֖PXGoZkW")K,[[hWqW^YdSTn`iA>B[WNY13&0TP]@J9RKoVKToOkh5LFZkE?>IgOI[<5HPLSQAVL_hN??Xh[OeaF96FNJ}t82$N>H`SP@vcY`_gF?;B]JB6!9ARHKITG=OR%,]JFi2BnTMt`\VdTrh|V\PjjTv|T^8x4nV7K@I+=[;4@H<,0 > 84(:|N B]C>,@'G76zP=R@lB8`0"|QV"^42w2ZpRb(`R63A7?BJbV^`ٱׂ߸瀀ꀀũָӄӀҀ ݑЛ¼лѼȿѧɾґ˜ƄҀɥЎ~}}~~~~~~~~}~}~~~~~~}~~~~~~~~~~~~~~}}~}~{}}|}~~}~~}|~ ~~~~ ~}~~~~~~ +~~~~~Pp\\|0x଀Hp[&11,P9ju\etے}qoroag`q{Ǫxg\HK. +!K:mi7=l`wJ;Gus0MOR9YcKOSaHV7)B7OK*MG11QOZ4,)+Q0Uh,-hb."J25-,98<3gQ[S[oj,xy.vZN^43 )  . /.:P>:Z>NHJ*:<,\P" .(BDF,.B4,.0&B4`LPl,PXXtx`pPxĄظЈبԄ숈̬ȌМԌ贞ȐʨȸʋⓟάǔҦĈ÷𖚘E`>@XHF[4jV:2"-+-?,G_CH@=;W"$=;16Tp"dYhR0@# (&.4-46D4M-:0%$52'%#$'" $! !Ddm8&AXk=CUjD;T3()PD&Z[\~/C<"G*3`8"'GR=-9\g]v.^T(&4.HJ$L24F8.02(@2XDL\t|DXLhhPX(\pD|l|npXx\Td|Xppp||XjDplr\Ѐllxd|lxphȒȂxԘĐ켠򻩆}ŒȥxPxдЍ斢Դ͠شǹ4W$RXpXTdXrKE89KKN[HfUZSb7]x@158B\:Z6QACa`jO{\ X[_7LD'+J,5b9"'EO7$1S_Vj2@\lbftDTXPdLpRTtHlXl4:O.H02PIT(?\DZ,<2`lb~І쓔߀ķĢЗҀ뀀ˀрõŹࡊܽɩLӭ枠ƚҦ􆖔蒒쾋䮲~ }~~~}~~~~~~~~}~}~ ~~~~~~~~~}~~~~~~~}|~~~~~~}~}}|~~ ~~~~~~~~~~~~~~~~~~ht xhr^<66--Bjph\J8vW"+8dhKPPBd^m],v^qțfloJH}k}L.@! )-26/dU"XMEj)waY&:CHCAG=QF2D|0W`Vt0',-'Q\LI.GI//10AJkYwfaU=&_S4BcP> +# + #<3&DXd,:0J4>*84D2,6G?B\_f:8-5-!&:!!2ufN-yQj(Bk):3$/M V=W>$5/F2JBcmr*xR`^f|d<6V6F6@> 85/W@6,C7-?8(:fpDPd^txt`pnF= 7-.BdfTD.w6tR#"Z\=??1FBalpOT*A:o[.^[U|;wp@W`hSQVLl_G]Z|zFeF)V+4u?N@L5 ]g`<26)?V5*:6F4.8@J$B. 4B&`8PL4VFRhZ`DLP<\txXPpppXp`P|jthXP\`0txxLTTrpplhXtx~Ċlxrxplȗ̒Њ誡܄а꼴؁ΛڼԘАLX$d@pzXzRZrRsRlnnmGC@U?I- HD DDF(82]B^.nLT$4iB 2TdU`>JU,YbiRF*`\ 8T0Hj<@p(|B*d]NF9XAV4)q9RD+%FJLtЀp8چ贶㾻϶̲ބƇ׀ҀĀ݀DZǺЪI׌迦򔰳ȾŽ~~}~~~~~~~ ~  +~~~~~~~~~~}}~}}}~~}~~~~~~|~~~~~~~~}~~~}~~}~~~}}}~}}~ |~~~~|~ ~~ ~}}|~~~~}~{~~}~~~l|l~мp^T:=#3">aWBvd|Z^lI0CQLLdKx[@^B?DGf^xíӐVlv]JN8/fw|׳T8N8&I" !& :f".2[pn&?JX:FCtOeeFMq(M387/=N5TY]RDL\Q."DTyhcb[f5WLH53[EKhI55FN?/5#9TPLlhOBitm/s{\-Mh2 '"&K'6!'2&X4,:dN`6NF266*",<8&J4>80P@T@tNtXhLtxh`h||t|hĠĈܼ丌ؤ̄Ȁؘ༄ОڠȬЈ谸ޜ̆ԶЌ΀ڵ墀ē̜ޔX|ԈȂȔ\4Csa5+!/;72@G"*ftd`XT2:iCC3;'5.F36c>U5E54H%( !$(-("(.0 ';+-1 $>K 5*D *?6culX]gRXn?b.84'J*N.:2>A9,:6235*#.e`FjHtH>xDT~LMX>,AB&<5Q(0:(:I6VE4L$TJ4b\Lhhdrd``LM0<'@`T8hRjDDVF-?L@GaEoP4D(39;LRjחZozeRV=2etvԲhDVL3X4 :@&Q|8BGe1@[ihKgbY{|a[GmD[Y@bqGy}raXl\B2PYniecp>bQS?<((kN\vSYMwrIeLJnYSrrTKqwr5yf<^|G6>-8+"CK7)4K&sM;T;?E<,6^4$.T>P,FB266*>\<$@(.B:,L8B<4X@T>pHjDP4`hXPXdld\T`tHdl`|ttȘ\LXĬXxnxnhp\TdtddfHpH|dtȖर||țҔԀ|X xȄؠԐ޼ؑԆ|ȓΊzXpLPd|pndPrJ5E9ELF?LS(.Dzpd܈RX~io^d2O\@V\;OEI-SY`Lm[Oi\`eh8f69?jOZYX-Ial;=01I?YA9*5/+ +*,%!.c^EhBj:.b.Fv@GT:,BF+#E=W'(2&8J7XF4J$PB$\踆Ġ̲܇ܔɻ۰ȫՀȀހ׿ĵ˸ə܀ӀȀ܀펺ƍڻݴ̩Ů䴞̴˜ļĸА̘ldLth48`|XxLPpNDtHd$DX$4<4 $l`.,(2480,,(B@$4 D:VHJ,,:2U{B VH008P^h@** + +C. j807t"6Evj!@MA +28ȒĨ@4@Xr܀̇ܿ줆Д׀lɾ̶ƅʽ즲쑃ܕܰ֠򦑯ΐ~ ~~~~~~~~~~~}~}~}~~}~~~~}~ ~~~~~~~}~ ~~~~~|~ }}{}}~~}~}~~~~|}~}~~~~~~~}~~~~~~~~~~zvlpv:]<*4=&7+5rVN^X8s8BPbKyvN3LDJK$hp}”ht@F4I="C>2H~EwztVnB1 +!6(-CZ84>V9,I1c\=:/w35QA<^:J*GHH33LIFBEX!97WPgf:7ZSdD_=,L`JnlpE:\Z?9H(+=CSrdz[VS@NQP`ZC"  ,%4/"$"B((:X4ZPznD<08<@>H$&>D:42D,@FPJ`PpL\`\lRpY{]cngzWahj82HVL1R7:DP/6B+$%;86<3;J#MHE^O>8L(0v4@PXF6I:E:F"3bHd(DLP\(`\xjh\`PԆj>kQ=DG,9&-`F>HJ4q4>K]FunF*:4A@ blw×qLTDRH.PF7JFwtx\zJ(?)88QD8JPvTBLsVIiR*ROTJLj8`]]pQq]s_^smfPRo2GAYSjlF?`[rQ)lI7VjU{svOCpWiyWAc`ivge[[LSXSkj"3Z132);MLC1F\+L9.60X:6:P*J>h\64*4>D@J ((@H>86H4>JTPl\pPXT(d^T``Xt<,\x`HLh\`tx||Tмz||rxplpXhd`|jdHpXPpX̤|tvhXphplx|Ȉ̠Ā򔏅zt؀ЀpxhԄ䠸ʈ스ʤȒfXPTZPjxа|>dCS4Ja97oM,DFXȬZF\ZE`8WOZyfI]~vJeUKMWXW_3Q2N[fS_[QBc^\ICXbK:TR?+JY9TtDMEIu_qcblh|[ekj6*CF:+P:>HT18C,$#63/5+8I#NHF]O:2B &h(>`Pb($8B&CF?Bg}*5$[[SA#<_hd2!&1*  #<#+ .4:X<\FJX.d68.D&2p~H@:8P@@dJ8`PDXNxlTzpp\l\px\d|\฀ज̔ؠԀԼ訸Ș예܀Ģ̶̨謈Ԉ萼踏Ȝސ䀘ईȨܞȆƨ򲶶໸芷–ஒаȘ䨔Ȅj9:7$$PlQB8\@@ڰ쀔АvG) 2CRHHTMKi9"1"& &,"1 -%7%0) "&%#B8R=!BdDCPk?jF?m5>^|VpPGL\dYYhtENtTeUuxOHnn?=:6+@OqKRVEeHNROmZawH:?[v?SZlr>GmkNXw1;.af]J+DdtqD3N.J48H ^060F"0n|D<84L<@`JDlPFVLtl\.F#I@42F7.*0D<26lBlC6]>@d4p`h|p򼾌ʏºڴΙҺ쫿ֳُٰ«ΝҨ쮤؊ڬ»့뀀ˀƗݩزùˀ팏ԀึתǟǴɵȼܔذ؄Ȥ𨨌pPxphdHXXX PLLLP`$`hJP@$x\Dlp (d$X4p<*,84D6NNHH` `@4,8 NoE @2`p th\dX4D00PlP F\"4&Y=H H[W~UA$:"Kly86erMZ:06@dXpP`\dx`xtvt8\|܍䐸Ƞnl>LH˹р؀􀺿؀gʯ܀Ծ݀첪ꈨښ̦򆵴Ԯރ~}}~~~~~~ ~~~~~~~~}~|~~~}~}~}~~}~}}}~~~}~}~}~~}}~}~~}~~~ +~~~~~}~~}~~~*~}}{~~}}~~}}~~}~|}|~~~}~~}}}~~}~}}}~~~}~~~}~~z}~~~}~dPĜdH+b':\-:%>T\db_dQa|yg\`_Ugk|ie~Ge=PfB/,4WO[RP`ZLWf,`(HODGD*@32^3P1XQ95R>WO/8,-6[O@47;;96.03%:G+16,,#=:1GP9PM6,e:?4(0:@&)+!&Jo-ZqM^MKNA,- +$ $%#"* 66H4RBPjR^NlHN\2@6:B/'-+D?E).#05B.0:,+*0T$4.#4*(79.$N^rR\L2jJBhz@nHl5>LR2|L<Ԑ`G*f3K6l95SPF]cCc`I?vSgLAbJjjLIC33Uy6eUmZWYN:,@,*55)*#LI(QD;G6;4.&*2$86@&D4B\L\RnJLZ0>48@8P68\$:Tdd4!!)@3+30NCI*0"/4B.2h@g08HR4̘ؔҫčԞ汞빸ꀦ˔݀ȏϵھŲߘțူ̀ڀӀ怀կŘê܀ǯەրȺҳξŬƸª괤ƢԪԄࠀȤȜPxPHTzXH\PpX\@@\<P4H0h\ DH$84\t Th< XRf(n.@"T4* <,< D<"f&T1"^C6.HWPb<$hXf0DO]U9d2V7$}2!..?C[V`>0.@Jdn\Pp\dhPjh༘\p8f$: .L@씥€iӪ̵〖ڈ耍ȌԄܶڹƾ~~~~~~~~~~~~~~}~~}}~~}}}~~}~}}~~~~}~}~~}~~~~~~~~~}~}}}~~~}~~~|}|}~~~~~~~~2~~~}}~}~}~}~~}~~}~~~}}}~~~~~}~~ ~~PrL^rtlJPCW%%/S>7)$g^jxZH|`fqktb|֓nQHq\kqEal*/,"8%+/:(PNq\B9p96@6n1,e2P>\G73CQ=BMAGO)4)K,3'"827L-IA:=/DM_K9H@DUR. !&&)#7"?O*+5;XH05  % * #<-@?SNXS + $61,*0L\DpVHJzhF4>@6PH0<6\`XTdLDXJTLtlpP\rhxTtxlpАܐԄ舌輰Ȇ̔ĤΒМԀ܌аľҰƢӨȸЄ𮴊䒄IJքꡰ˜̐@ШԨTLZIpPtИȠĨРhjZKjQQCAIT78>8%<*4- &&*=2?<*A42#4 %'%*5OF/&.FO_lu9IHD^P:qZ]}|rO\256.+!"%8*B-*;)%0336#%(!,0%,:*=3@6.L`^jhnVTl`LhPH|\h}0\Z<0h4VjnxjJRF^*23=^D8' gbp|`J|_eqkviٕnQIr`oyLl{>FB:O>DGN>d_ylB8zCA\VHC.~KhJt3_C@\^X_kbitOH=r@^XXmegbBnd]_Qdnj85R3]TUfcA58>9C?RF8D59U?XhBCMSqiMLn;BPM8I?B5.,$5PASPd^`c'5*31/# !5;KNBa:1I:6<$&.4NX>hN@F |iJ6:>4LD*60PXPJP8'/SG'NB%3P^T`ZbHH\~ZLfLDrT^z,Z^@ĄĘऎ“Т򀟪借򕨰ꏊĜ֝ϿǤ҆٦ـۀԀ瀀ހӯٺľ۽ɀʵ̀ҕ㲜̤̲ԕ􀌆ԴМФ|p0xLll|TPP` (0, pPtH<48LLdXT@8@4d,LPD0h,rZ6 d0,(HLTp$80XB@; <5O#3,Zf,P$8^_,^@ @blD@@x@t8d9txfP,<`xֹՀр^ŶˀҀ̴ȫż֎ʺ̦ز ~~~~~~ ~ ~~~~~~~~}}~~}}~}~}~}~~}~}~~~~~~~~}~}~}~~~~~}~~~}~~}||~~~~~~~~~z~}|}|}|~~}~~~}}~}~~}~}~~~bd~>L58NGL?.8*3D6.,9E;7-W:Ij>V?@*R3ed$ !",1X+A44"! "  C)X1`   +"$+$&"NLT:Tdl( P6*B040806\dA;7$;%%#9-"%'6(FD9=04 0.*&04`.042JsvTafP+_CUNLJf9&.nWK H?(,!$#6#02/-6&:**B&. .,*3$/*>)",BYNEi\TViRBG:naZO%wJjOvLXAjI|{=-(?AO3C>/I9=DB.2,6*0P\8DL8P$`N0XTp4LPp`@8f`lXDPhTTphxtPؔ|t|xhhplHp\nĀ|Xd|`dtdt`l`lplxvhdȈhppdjrzƣκȏ{ޑڸ@ܠPzبĢΌx򠙔ԤܤpЀаи踨tppP(drdPඊtztˡFR?^_m9TBS\_aP+7.@RFHV2*00,Tx8XtVvSH4h:>9,`7YV@YY0D<5AKvYER:DcpJ#.NcC)20*2 '*AR7&?4(714HN6:&.>VL<^r2<46JNv|VjjnDN4.N>2RdRR>D,&Ss!/8HNTmGKz<}xwgsĦuaoFUO:MD:CC:`b[[?*BJf?,:12)4AT4I^[LW2]NIgRIMNRK45e[e5_FPJMX][EIJV;10&, V\42XP 6H<:6.D4P00NSgm/FE1).>J`N@^r2<42~@FnpPbdjBP:6VRJژ֢ȆΆ˫·Ѐʬôꜰ¿ÛɫߺÀƺπӵ۽ɧ߀退ր¾է֯ɬƻȗܰԔ䘊䘄Ь蘄ظh0XdHpP@l|xlD hD$FSQ8NhCYX +@<~DhZ Px``@((@Dd``402^`Z FMx^2 pDؐίԱ؀oހܼɾƳ߷ؗʨʆ扆Ǯкڞؐ쎨ζȟ ~~~~~~~~~~~~}~ }~~}}~~~}}~~} +~~}~~}}}~~}~}~ |~}}~}}~~~~}~~~}~~~ }~~}~~}~~~~~~}~~~|~ +~~~~~}~~|}|}}~#|}}}~~}~~}~}~~~~~~VF@=TFlt`dH:0>0fh4,0L0*,D@D\<<$8jZhT`0,,L`lxdl@Xh|h`pPlȐ蔤ЈЀ謠耸ظȴȀȨ좔Ĕ䬴ȐȆ䈶ŬشܙԼ̠ܜ̼ĐĻĞ򈤗Ҡ؂؜ؠ@PxpҸȌĒgL\YkT~Ķu~?_55'3"%:;-$!RB?10?%&/!: 6?=}e56R5\MV?$1TNbUaTH$4Dmj1KJ8(7"1 %+ 6*-/:("#+EHF@50TZ`0::*HJ0pdNF\vXP&Q=4H868NBfp^dBd2O}W]NlkWyS?[_`qʠr͇p@Z:M%qCaNV/qU?D1FS0+6$(23@")'6&3-27;DXDC@_;FCL:nhfB6\y_OB?VdnV30B3NNRO;L=GEFGEI7_SYMa;R>l=I.ANNT81EGJVG=N]DL_OS08=HA(6B-)09>:&D4*.L(6JTB6D4hl82PbQ<=B>be?j__nTSXF[@RVNo;<`XrZX4&\IRr֊аר܀̀ۀҚЭ̢ϐ􄨒׬ͪ׀ٰدˀ瀀ꄀڸťրـ˨Ԣ체̘؂ؼ䬠Ѐ䤌ؔجȈ@tdt`xp(hp THd`p 4D\@8< H<0084D OZA@R`$=I-#U0F&jT(fPVqv`P:tiB,4W8f=4@&rk8*`\f \D`^p@xdXlp\]IlZVRHJY"< <nЇ̐ићֶ׀ӯɵĽ媁1СҤҼ  ~~~~~~~~~~~~}~~}}~}~}~}|~}~ }}~}}|~~~~}~}~~~}}}~~~}~}}~~~}}~ ~~~~~~~~}~|}~~}~N~~~~|~~||~}~}~|~}|~}}~~}~~}}~}}}~||}|{}~}}}|}}~}}|~~~}}~~~@6NBXDfJj^F=dkH=XcB?ZJ;ONo^VZUf|Ȱ‹vaPJ21@JNQc!< +NG+#*)R;& P) #'*("JH<@HDVJ&&4,4.08,T4hdPHPlfx`Ld0<@8hpX\`Dxt|XXthX|x`xظмШ𘨘؈̀мؚؼشȈ󣾇̽쉳ʪ𰖨ϧܤЈ򀸠آғܘĠظа踾ҒʨvРXp\XbKnĺga)]L:1/3I#3+E7)951:910-'85 +sSMOL1A+9^>MKEa&]&% %Db?&=FWcv$ 02$-2)!-S.+6.>R,F~D>8RX(6f`Rj~<8.X.*5:0J@TB`Fh\JEjXHcnMJcTEWUvfd$^Wj~̵ŒkbZ:;MX*ad.v7W%%g_2$%<)=#&0(%2'=CG90@>LD2.2TNBFLFXL((80868H4(\$4h\H@L\^pX8Px$((X`HLxL8d`l8HdXHdHH8XP|x@` Xlp`@0t`H\@`PdhdP`xPph`dhd``XX|`x``h\tXȬzlv~д|蛮²Ҁڃܺ|`̡֢xԐx@X̏ܨ쌼ʏĊ0hЬ@drphdЀxppȤ𰾂ʖ֖дæpXx^\hNɀuq>xncL``A_qGS=JV]?Vjieolb]TLYR ~UQUW8R@OtRa]$Xl$V#1&0>FT\>4RYf)u*7(""-&#9;.:D?!AMuP>K5-6I&DP$\.Fz @<6RX 0ZTHf|>>:h87CjސڹȳΜŇ؜쏜Ô˔ܦح٥ȈրπϞ޷䀀˻ǼŮũ¢´肄褶Ȁ蔐̤萰ظP``h|P|0xpX(P4H484(8 @(0($@\ $XTLVR40``$PD(B|,($<"D&0H *:&iX^$#MHLtBDTw}UL 8^@~fA08ZKt_pQbjSt~|pwԼgUjv++ PH/8#&S&"  +, )3,>C<"%*5*%6<".>Mgj0(yPS\aDOQ[[B)@R6G62:I7%Ca)" B"$' +,#=/  &, $<48D*688@@T<4(,00P88Dp0Pdh44,,THDXPlPTPP|hXT||`ttȈؘԀȀܔ؄谜АȸĈ쒸ࠨ܂ȞȂ܊ԝ—꜈֐ږآؠа촠Јx޾䖢،|>RNV:wZ\,"017R++Q=7 -%&0"<92<FKDeIV:2,7R.;6BPkZ[UT 4 Ej\DD HFB"-57.37"#$N]:-  2--O 20\L(ZRDd\:LJZLNPd64:%MQd.b:F<8>BmR_owvp[7/YOSKM7<^N|gzWhoVwryr_~5!8.%*e]DM;97@j("'+62*!3'02&'TB3QFIPKIJ3I81L.:<:8DP@44<@@`DDDp,L`d$( p<0,@<\DHDDp\hLHxll@dxpt\Xlh|hP8p@xXlpp`\dd8P@ttxXhdX\\`dtXlxpL|t|dpTlpp`v|x~Ȍpȶxvzr؃цАԜPp`ИxܤȼȜ̨`| pp`ȌXph@p`Ȑ𝨭¢hxĄDTNX@ÀjqF9G\`hMTQAmM`;^ZM_Xi[qh^c7UXUkNeB=9EoJRJKWtZVUX*&1N8,fzbb#]YX,7,A',FH=&*?E-69?k}\P3-<'+QHEd+2/J8ZBNH<^V4@>N@BJb8@J.2[_ĬȀՀπ܅؃ܖʈʌƭ߀񀩞ŀ܀퀀ӽ»ʭĹƿнƶּ䨤घत𤌰ЈxؐX@h(phPd``PxTPPh8LDH(8<0X48`<@XH`D8 (8HD,H \0\F HX\D0.$Jj2&*B(`3"K&*3B6QP2} .DnWb28$J\{$4e*t0N}v`0 qjhc`t@` `H@pt,SX[@XL@.@>a<>84,XȆhE.䳀XÀԒ¬ηɶה➸Ɯʸآ ~~~~~~}~~}~~}~}}}~~}}}~}~~}}}~~~}~~~}|~}|~}}~~}~~|}~}~~~~~~~}}~~~~~~~3~~~~~~~}|~}}~~~!}~~~~}~}{}~~~||}}~~~}~~~} |}||{||}}~~|~ ~~{}~~4**^F..@6vk-O?*mK9&/JEF2jVPubt>p~~_D[<(" +(B",4)  +   % G=7N(1-/ 75 *)ZM6^HM LUO5S>3BV1#,5>+H+++L#21+;2E +*4  " $+%/"  6*$B4P4,8X,<@LHL8X80h8L,D`H Th|h@l8H@P`hx\\XxpX8\xxhhа؄Ȉ蔠ܴȜ̠ШȈܤШĎа𼨸ԘȋԬ􀡢꒐Ă̂蠰ؠ֌ҐȐ贠𬁠𐰄ؘpʱȈxؔJLЀ̈́hG/!) ?-)(34)+1- $*&:*09B]KFC"5-8DHLQ10!A]OS,;(/;6!"W'"L_D) %C(Dl^*t2wo9XKBTK6AYWXArYPqd|G~ČȎi*OgT(&:5-#/<1-Y*!2@L"8,!0( )1(9FBMDh39,=B?BK1&",%37D6)!-=6,;32@G63)>*L;#4E;:&<&462*:*<.T844T(::H@L4X$LD|DX8PdLT\p\(b$4(DT`pXXTx`|P(LXhPPh`ppp`dhXPx8phP`ppxnxxx`|xPhht|txpptPp\`@t|xĨȔx̰jtntwz‘ڌdz~|̒ pĮȀΌĈph0~≘vhx耠Ŭ쀔ĂĈȰ쀨`TTǙۖfTJVFPVWPKBXLT`fnoAEHCnBDKclZfRCW@^kZiRuYX4f~y;PgILAexxHQ>DPL67,$#/f/4&/,;h~eKBA13=3+/,,A^B2Xx^ b(684T\PHH4<$>z>t0p zt:X ,&r>\P<P8 G4_<8dhP`0PX0hBsZL2G0|(0hPP\>[\րɀfԀ̀܀՜ͳí٠˽̿߳֔Ԓ蘞Мۇ    ~~~~}~~}}~~~}}~~}~}}~~~}|}~~}}~~}~~|}}}|}~}~}}~~}~} ~~}}~}}~~~}~}~~~~}~6~~~~~~}~~~}~~~~~~~~~~~~~~|}~~.~~}|~}~~}|}~~~~}~}|}~{|}|{|}}|}}|}}{'~~~|}}~{~~}|}{}~|}|}~~~~~,D6*(2Ka%SZ+HMqSJM]@,<^l^ZOX|jsҖrr)l}2#%D$*"   242 -4%$)$%6YGMNd> +6$$FE3/DY;(-!2?5-.**D8&= + + #$% D, +  +  '$3 &2PDD4&.:$420@6@L,dC^8M&9MDJTY7 &'= LL/+'#)$1'!5S+:F\,B|xX6V40&ZH=GP1%!)6N@<8"6K`'Xb?RW\SWnQ-:R"\i~bfVfvu؞}|5xA3 *1-,_,0!"008%&62.:1/*DSpZTZoKJ;>8ceUD\]uYEgWKaj]AAR>j]Lb010HHLJqD@3)/@40!*62.*&'.$(@1355;L;-)(,5D*.8L<4$(:(:88H6@H(\8DL`0LTH,$4DL`X,\H4 (ht`@0pT`HT`\hpTTXXPxhhX@xHHXxPPxhtdpX\pxP\pX``hrȀx|`xHphnpXxxz̠~~ltxpҐPv|€솈vԔذʲzX@๔ژ~XƊ܌xpȰĠẍп|舨Ѐ@Ԡݼ잮xԤxt`l~Ԁr=bLgVnwzJMdVc (L0 J8&$t\L>D'8\" `S)-+" ";B.gL( (*f*.8,`LHh8\Ly* +P^@`Hhp@PpCdH`(D +*\@h@D, ʠ|[^0ޓ쀀d󀲀ʀȨӣ॰ԴФѵȐг՝ʊ Ŷχ ~~~~}~}~~}~~~}~}}~~~}}}~}}|}~}~}~ }~}~|~}~~}~} ~~}~~~}~~}~}~}~}}~~}~~~~~~~~~~}~|'~~~~|}|}~~}~}}~~~~~}~}~~}~}|} |}|||}}}z|~{~~}}}}~~}}~z~}|~~|}}~~ ~~T$:CH:3+h6sqlMNMt\g1zd]_p`j`gwjzfzv~r`TRG7D-8F'"F@*'-C +!)+',3"""$&&'  +!&,(*<46:**8,, D088(P<8488TLL<(|P hxDPPpDPpTLx`Hh`|𐌀ଐ؄褀ȀДРĤȄܠ̸Ȩ̔ܨ輆Ȅ舃ЊƀٯŀﻶȤ𙝔̀Ȱм|yphxȐȠp~莐~xȀด̨dzЀtr^P6,RdA9DAJ!  +0$BN7FNe?_LB8#BCS:@ :!PBb`/ )Q=;**-#*8EI+(""/$ :lh. +0T(d>D2f,x}wS^U}ex@h_^pbojjzm}mrb`N@V6JVRUH 6)0()(G1$2,>.BD)*2(+083.UADJB?O4UUOMXIJGEJehJ^MSRc^YWSE=}TlhLH,:L:NYXNNqHNd`liCKF=kh?QZp8LPWYGJ=Z_)0><4,0 D:DF>&-!!50"$4$4",&408<,,@44(L$88D(\@<8<8TLL@$xD T\,8NNgDR'C.^T+wu./J=G>whgYACNS=7'Y6A2@hdB+%=$(8.;93OF6-:4U~/:D@^0j@B '#PZLx<2i\22m6@CBjm,]4<c83Z<(```@ rAO_'0 .` 4P0Lllvk`r ĆpܑܾýɀԼż往‚݀ѸͭΣ-ƿξ⪼̐ʌҰ־ ~~~~~}~~~}~~}}}~}~~~}~}|~~}~~}}~~}~}}}~}}~}~{|~~}~~}~~}}~}}|~}~}~}}~~}~~~~}~~~~~~|~~}~}}|~|~}}~~~~||~~}}}~} |}|}{|||~)~}~}~}}~}{}{~~}}~}}~~}{~~F#U%7"""'3H@<>-1[@0J5[:2AN4P^>`d/X/6-/8V%30S %F&B,/+EQ= 8.0 +.6/8 !!:,Z^032 JL8FK%!3-#"&@Dx^a^l;maonXrrDRr8Yq73`TD`fswnbd\cjtrJiKaM_58YYVdInPMq_2X:;[PM7GZ;J\oNCQy[jJ?BaC2@[CJ1YI&+-=5;5 )/%*"0@2*"(&"&DHL0LDT@D`HHdl(xXB4XtLdpH4X`H$(LD0T@D4@LXYf@hwHmp?,,UEqsDFD0.&\n`Fbf@5C:-*/ҀȎ؀΀倀ƒϕἍ쩀ڒ⏲ɧβ܀􋉗π€۬Ѐɀ׻Ҹڟ׼ꁊڈ䨴İĜ𤐔ࠀYtLxt``xp`xdPh`@hhP@P@ht8Pp@@DH(P8`8(PHD D4P\,B,4n(@0( $`*60D/ZgK><" 9$Z*7hk7FE6$""VZp/6*rlKt$ X0884$TD$RH\rU?f\F:TBv8H\`X\@T*daK|CH)SQ60.F (`XXbXh™nx@D~̊Կ¾ؼ߿˹֥ӱۭȀ¤ű֚ާȸädz~~~~}~~~~ }~}~~}}~~}}~}~}~}}~}~}~}~~}{~~~}}~~~}|~|}~~~}~}~}}~}~}~}~~~}~}}}~}~~|}~~~}~~~~ }}~}|}|~}~}~ {~~~| ~}}~~~}~{|~ |}|~~}|}{}{{~~~~d^`YC1cV{U]i4QXB@$b>*7'\",*'%  %)(*$)  9 !;#  %(2&UW3K#20D* -89/'&,7 +IA  + + +$(($ 66*, 44H2P648H(82D$0`4 |XXPhPD,8,D $x|PD`ph@\dLt0TxH`XxHpȈฌȠਬЀࠠ耀ДШآ쐠쨔Юؖ𧅾ŀIJȖ𘖰̤Ђ਄؀̀̀x`ܬ؀ȘԨج~ۓhx~|ܔ̪Ȑа਩ĪpdfcR-%C-'9&!+$$64/>I9%l>rL,&:DUd?uI{{Y`@'5qV<%/02%! 1&-,'&B):8%.A'1/3'"'-$@5825C/D4,*":24DH:686:8:>F$",4.$<),FT"(:(<&86P6X:<@P0@6L00`4$\\TlTH0<0H$ll 0L\P(PXh@h Lh8XHhx0hxh@Рhllxl ``XpHpppx`@ph||X`pd0|Thp@ttddhthpxhdppx|hhfpxޟzxޣzܔz@pƘ@p|`ppp `شhм޸訰Аpڰ؀pxବƲ̀r0zjb$AE;_GaT^yBSQTB'R_C9ICF834=5SJOIN\HUXA>7FEzdcYMA8)40koB4/<+4>=9Ҁ倀؀˨ҢⅬܻʮм܀ЀֽՐȽ΀ެ䯰Ƹڛ܂輠䜬بiXplp\hd@XHxxP8`PPhh@(<88HR,rT 0HSnmV:t66S,"+;nOTFHFhƖtnܠ؂ž云ʽعռʱų΀7ӸǺǼƸάνȾ ~~~~~~~}~}}~~}~~}~}}~}}~}|}~~}~|~~}}~}~||~}}}|}}||}}~}}}~}|}~~}~}~ +}~~~}}}~~}~ }~~}~~~~~}~|~}~~~~$~|~~~~}|~|}~~|}{}~~|}~{~|~} |~}{~~~(~~~}~|~}}~}}~~~}z~~~~ |~~l\mo\TtGUX-=daU('/#:w.!+;ZwHhKm`MSOPWd1N?<`>FJF0F6-@,2"('24"$3#.)!/**>6$7& +E:f?-184#$v_msr]qKXZ-d_Qi9I0dk?I@I;I7JbSEqBRad /!234757A:yF(&!9@>6>BH6656=.6Ib6(0,PT.P4LLqH-HmrT)44$V3(>FfDBthX$lT`bdJJ\YjRhBvh|.Bh J/!SjjKhK{Oxb48W0(PO_~`>44P؜|{Ȅ䬓ëYٰϻɽҸՀؼϻͽžлһդƼե½͇~~~~}}~~}~~}~~}~~}~}~}}~&}~~|}}~}~~~}~}~}}|}}}||}}}|z}}~}~~~}}~}~~}~~}}~~~}~~~}}}~~~}}}~ |}~}~~}~~ }}~~~}~~|}|}{}}|}~}{~|}~{~}}~}{{~ {} ~{ ~}~~}}~|~~z~~ ps^zSj[RAXaIsKRX(.[5ND--%, +&#'#`dcp>J0I93:*EuU{ynV'"LZ/15 Y"#" .+0% "O4#,4&04/    +  +,$.&$  +"2": (RJ*" +$$T,$80D,@D8(@8HXPh@,<5"5)%:C+0dqJ{8O1oJ/P[)8U*,AE!5,./-,&&2;45&! '""*6$,'%,(3A#%B$$2%@,)8""tq]^p_CRbP|SZ],3c@]\RW:@/G?4!3)ba^iJR;W+F@G$:Wi|wzh3)4T_$%HPCZFDHOEGu_DR]AGHEt^_kRIB9dYd_66V4RP:G6oV88L?U].-Vbe_D$&( ')+"2*58(*F@0,.N@@D6,6B:8.(,44*6&:.0D4J04`X8, +684pXXPkD<.2PZ4PM,K,:PE2899&GW_FHzRH`ETEdn9KgGK?GG39]pAQ>S;@B~b25REtE{F@^E@1^退ӀŚҴ退Ⱥ㥍Ҵ߀뼺۽򇑔ȾҾȼ耨丼f𐰀ਐTPph\X`pXX`X@h`X` ppH|Dphh(8|<<@(@$8(P 08LRHC`P(plxD:.$,2.44BPKrtH0[1`W *dbR0Q`b`K<]I(O0dNt\4$(tT ,Dbp\VB,Sf'o0<&dCTT BLZhb@TP\<$ld`𖀲蜂δ­Ҹ㸸ˤɸ>ȳ瀀瀀öг̽ӾݫǀƆ ~~~~~}}}~~~}}~~|}}~|}~~}}}~~||}~{~~~}}~}|}{|}|||}}~~~}||}||~~}~} ~~}~~~}}}~}~~~}}~|~~~}~~}}~~} +!~~}~~}|~~~}|}{}}{}~~|~}}}~|~~}~}~|}~~~~} ~~~~~|}}~~cjkTuNGZ:0XKNtUNw%%*9 7(%- ,>tk{Y0&,#(1- W,z4x|~|,"#)S? )1) 0 ( ( ,# # $ **!-GJ! )68"> " +&@6 $  +$"&\0b &4&4 4`D(8$L0X\0 @DHD0L$8H`@(H7+//3) $3@aNr[\LPH1aKGlO-&68mlF:I=R=@4:*$*P(-E)) !"(!$(()""8 ,$*&#$" '+:/,1(4F':dgh^i^j?4aXja`~,,07QG?fVOT5?DRu]4"0983 ;C>1hNz$:818#ZOAOI/=XGHOZPWcQ39Z5?JBChtHbQ]g77AiYQAF=N=7=I4G=QQ.-P]vu?-.8%',4)$>8*;\^3b<*$&&,$6$,DZP*..64,2<8*."4**0"0h8f*64H"(@LtX4$D0XBqY]Y_4QZBALK=EA?021G/<7A-7D@6?TQ.bB@a>S<^h5XVW`=J:l$)#,: JXXHH0 l:,4D hP$X:6H>,N-T6GHTFO46H::J0&d`(&wpXT༘섘Űÿ˻3ʽѤԼ˵ŹҰ瀀퀀ʄ 쇀~~ ~~}~}~}~ }}}~~}~}}}~}~}}}~}~~~~}}}~}|}}}||}|}~~}~}}}||~}~}~}~}|~}~|~}~~|~~~~~}}}~}~}}~~~}~~~~}~~~~~~*~~~~|~}~~|~}}~{~}|}~~~}~~~~}|}|}~~}~$|}~|~}~{~}~~}~~~~~~~~v~Zx`KHod@O-Ah46\S8(B&.2P6/2#(4;$ 0GTXdp\<*))+$$ &() %1 ,   +%B.&+ #%>5&( ($80 .":0( +BF8@ $ ,,&$$F&F04" & @XDX h\\P@((,@4L<8D(,dp(HxH@LD,TTd440XX48VPNr,]N.'0MgK89>AmRK(,K:>HO-5+=6#FZ:fxx|\$LB79%=8.@AJ:2OR8<`5OMYP4H2TsYoikS>jGGH4NhS3KO&2'%TUT\=:,$,'.L3RL<6J*4J:","$."*VZHP,..**,*(40::60(0L.J86 "*0 HlXh0l\\PL448@4L@qNe\72:Rjd&5%3>@P8gCFY1MAws9VefXX9_6?CEIF95+93;683T733NQ>kmsJIJH>6OdV"8?A7, 9Ԁҷ󀱀Ѩ⨥␲ǀɣڊڏЀπ׫݀ۓʾ̮̺ή´䰒ܤиȼԼЈؠ贴ȈxxIZl"IR%kL?8?=E(@`0V4$004( $6dh6 ;8"ZoWQ|kP82*@FYN"86D9NpVa:dv9DH<("768#2'' #'>8`<>bB   '54 &!#%  + + %! ,'K '4>.0" < $**(282H":D2*( (".>HN2 H<06D>8D@P\4@0X0D0HHDH$0dXH0THXD8HTx<\80 H<@(8`XxPPDxhH̀̀ȄؘؐȸАࠠЈؘਰĈذ蠀蒀蜔؄갔ƅθ‘ĭڝƲꎨ𨐐Ѐвܬ䬠ƻܺƠ且ʯpؒβ袞Ѐନ`pxjdtxVTL`jXPbnRPDLNH:R,#*?D9V?QPe9O0H7(18BX&/:KVs1&/+U1--(B89';+)"%$%!  *.LD/=1$(!!4#$(j{lAPeNy)/LKBBdh2XpDPb$J,4E>U%5:6ZC?]FPDaL``QUXufeX[efm9U8ckcAIKDRGCAI>S;95;*2.ACUv)=&<&"4)R`RL60.4H...,(.00L$6$FPB::4,0<2,$(&*:LTV6(D8$4LB>PD`l8@0X93RDUfEZFU\tCjNfC?HCLj4:AOZw90;,(-DlC$664SP[>uoVWgQ::G3CFEFGBA=43'A*+50>^JN\PCbME>C;7.C21*%#4'ݲٞ܀ƪľɺҲ䜪ԇĻĺȾ¸Ј̼ԸĠȰ𬠨ДĐؤȀ؈Аppx@P|8`XPP8TDP0\PL8X`xP`` `XX00xPTPL(l4@@X`<08@,<0lXxH< 8$2 XDN.2LF4JN\Z$  4 NBV 18ZL1XT yI-MJ2755Dglcb>x4Xlb$XD^ ,^dRwHuk@*F.$JJxV RS4BpYC\GTMR<$TvZ6.D0NB.8B,,$l肅ؠ􈊼Иր耿Ȁ殮娣ɫ݀̾çˀ섀þƻ~~~~~~~~~}~}~}~}~~}~~}}}~}}~}}~}~~}}~}~~|}~}~~}}~}}}~}}~} {}}}|}}}~~~{}~} ~}~}~~|~|}|~~|~ +}}~~~}~~}~}~~}~~~~~~~~~|}~~}~~}~}}~~ ~}~}~}|~|~~~|||~~~}||~z~}~~~~~~~~~~~pxĀʱQV@=2>W:x <3!!&!*/3" ,<6H6 " )3 ++ %%    +     +# ) &)4,&$ (& 4<4T`8&$"2@F:,$&$,B.D8L8BINIO@PR?$2LI(DL@4@,3(*;"6JE@K;OO^?[]BZQVXPVbiuab`j`VWUSbUVVIFCNIBJ>->='+3#,/R;??9%$4 14RLL2> (64*" .8.LX8.20(4(,(>PVL>64,4*:RH0+iP*35)0N,Ei@wfl]efeg +%M:0-5%V$)JIDPM"BIv&P2 8D@< +TD`fYX`HZfP0LmNJ>F6VFeXT 2|^PtP,ENE2"6R4,`X0 dмȞ򖌀䶬̬٠ʳȀǽр±Ыϫ쀀  ~~~~}~ }~~}}~~}~}~~~}~}|~}|~~~}}||}|}}|}{|}{}}|}~~~}~~}~}~~}~}|}~~~}~~~~~}~~}~~}~~~~~~ ~~}~|}~~~}~ ~|}}}~||}{~~}~|~ ~~}{~}}}|~~~ ~~~~ක{PA;LEzC*-1H55^  +  + !,"&(4$3<! " ,  + + ! *$0" *I8 ""* <"*D044(4<>\D@H2DB4"  >&8$8:@V6>Rl-""+0$2!! 554)!(5"  17;-HDG66:E82=;$I'="@6 vخ{URNTLW0+.+=?BYD0$N"05?)).'=>;7.%+<9AH57DFPB3?JR3VHB6XXKQh`TUQNW_BM[nUfkSXG[fGFIVcFCMQHX2>:L6\@4DH<4@X(0`T04LHD@0 (XThtHtPDLpp@\Xx\l@lH000B?HK`ddTFCoWYIHLfXQ\Y2=Uv<>^0ZN&%3-)0 ϹvܷЀĀȧūڽ܀ǽ݋ҲĢ脨ֺΠԴĸਬмȬȸ԰԰ĐؠȐĘĜ؄ذȄ|h8Tx0{Z@,4lHD 4H8T:h\"\2:<]I?,LA@ Ot$)iB(D07(dhx_ +RB 04@ \@Plj`l V^? >T2d8*LafxuXh8( 4Y_shi4< @>=4&!432J9!Q7+.!$"-D+( 7,''"# " TBPFCP%,/:+5@+ "uaVoO*TK0KA8E)C@DD~^g|fPCFKkPB(6* /A;F91:0-!>D6"!/(F ,,%4F<<45*0-,<&",44<:&(H,&&2>:(,2@H\XXD4H2<4,$@*,,(,&>@<:8\T@5@')9PuKFe`<@ELNU@E56/zTaVal?0-+$(<$7=V8IPZB,/)%̕y̢Ĥ½ĀͶƺŻž»ƥ¾𩬗¾܀̴ؒĬ̤̰ਜЈؐܐ脀𠰸p TDddh`\P|h,RDPXX0`08`T@`LxXxh8XlPD0X0 <$@H00`(04Xd`4Hx4Ht XB 08$$0& :8R + \S6z:$r*>B{$,(& [7Uj@$*]P0 <(0*BZ`Av, >ZW;(@28xn$] NPX `2K(I,mA8=P0(X$̮֢ܞ脜d쌌拙ڤ̴ɮɪͶ退ջ退ڮжۀ~~~~~~~}~}~~}~}~~}~}|}~}}}|~}|~ }~~~|~~~~~}~}}~}}~ }~~}~}}|~|~~}~}}~~}~}~}~~}~~~~~}~}~}~~~~6}}~}}~~}}}~~}|}~~~~~~~~~~~~~~~~~|~}|~Q}}~}~~~}~}~~~~~~}~{}~}{~}|~|~~}|{~~}~}~~ ~~~~~væ`@/xJ%AOB +) +,&%"#!(& +  ,! #/B -E4. "&D(HHBD8 DHldT4(0H04L0$(,04$ <4,  ($,8,XD4LP8H@4,"$">,ve`Ĵp40<DfTxfv~" K[E3?1.(1"NLG66$%**.+&2*( >Y?OYDQB[<'0%:#$E3,'"RvⅷZ63X2CPFFm><'1H=TVF\_lO(6HA?;G2"#S6C>GUL=$',8MI#4&.89=SQLKUXHLVLSHK\Za^MI]Zg6^XJJI=D5G]G:@95XM(V3P$"8h`bb`蘂జѿƹ´Ż焀р +~~~~~}~ }~}|~~}~}}~~}~~}}}|~}~~~~}}~~~}~}|~}}||}~}~~}}~}}~~}}|~~|}}}~}}}~~~}}}~~~|~}~~}~}|~}}~}~~~~)~}~~~~~~ ~~}}~~~}~~~5~|}}~~}~~}}}~~~~~~}~}}{}~~{~~~~~~~^utwmf\x,G<yz2d"4 ' !0-  4+';.)-0 " 6K /# +&,.'%& + +  ! +!#> WS'((8, 2(484< 4PDD@ 8Th`H8DTH(0X0@4DHT088(D0<4((($(04X<@48(80,(@/EHN^bKYQgU^\]IUQQ=VUV;GaJV=9bH2)@D2M<7D/-&4478@JdKSOUUPXVH+V-8*( H,(>*0"3\41F53HeF,ΝםӶƐڇ𔫱πȕƶݻ耀怀̿ʺʼذ̼д𔜔Č،ȐĨبЈࠨppLd|p8D`ddL8XddPPX`(H(80Lt 8PL@@x$t lL4\Lp~^H\84&(8&Z> fFb 8,6D"6{Q"Z2$L.:& FH< Hd=@ h($PDpN4,4rXSqVSgdgHZ@ZFw(zjr6e8LH @<M~J,vD@VlHP,R`\tttd|l8XX8xJdR86:(A:7 47@E$' $#  ,32#++& )6Kck}Q_@7%=H7"2**!(& ,34$<7(/ 2$Zdh8ZT($tc@ou`HD0>n86:%L!2"190'->CC3( 740&42V>H(% %,xdadWT`DGHEK4:F;2AH66>.!35XW+r.NN8( ,&.,.4,$*"$>@H@DHd@$0TXD0,,8HLD4(dH@TP88\H$hLT8TXTLHDHHHTH4<@P$H4D;U_olsZA,DO=(6.,$+) .7>4,+2XTHR16<`R柴֌򁍑ȓڌдрӼĔр亠Ĭ̼ļ̴Լ̼РȬؤȸPp`xDP(8ChXL\``P`PTX(THXH`|l @ H80X8<, H| H\@hH$4d|&8<X(fT`0 H, (T0;S6"/<&(2n`02[8:D",?OV`DACNU@>Gm@RTB;ZJMh.~ZJ xX 8 <dHH_ 0[,J4irhȪܨآ،඘֭˴½Ļѷǥ¼糺蘠ܗ ~ ~}~}~}~}~ }~}~~~}||}}~|}~}~~}~ }~~}~~}}~~~}~}}~}~}~}~~||~~}}||} |}}~~}}|~}~~|~ }~~~}}~}~}$~~}~}~~~}~~}}~}~|~}}}~~~~~~/~~~~}}~~~}~~}~ +}~}~~}|}~~~~=~~}~|~}~~}}~~~}}~}}~|}~~~}}~}~}}|}~ ~~~~~~$(nZL22$'$>*.Me:$,8 ++!!;Jh(7L&3 + +%  #   + # +ETA0BB:0* ,L0 4<<44d8@(<$ VY, = !#'&%2,88"&- TaV?91<0+,6<&(4J284,,B+#00nWTNR:25Q<;Pe@hBB4C0>*$)&Z0(H6^ +8(&<6&6L%*&"1^b:hMm_C\KPd<_kV 88NF=?1:=2((9'6^;w#+0J) /1F06.cND/P588䂲웟¾ʼn䚓֪Ɯ҂Ħ򦐺Ȱ荀ـ߀ҩہζ𦊴ȼظĴĴČ̬Ȅࠠ踨ؠȠP@hpHh dPXl0P"PX(8O.MO$D,$f.&]J&=XhT,T@hvLt\ $fX0wJ>R^ i?DF?=`",`xH@4 8tH8Q$>eTxVȖ診Ċt謅Ϳ½Ϳͺ›ͪΟф~~~(}}}~~}}}~~~}}~}}}~}}}|{~~}}}~}|zz}}|~}}~}~}|~}~}~.}}~|}}{~}|}~}|}|}}~~}{}~|}|{{||}}|~~{}}|~~|~}~}~}~~}~}}}~~~}~}}}~}~~}~~}~~}}}~~~~~~ ~~~~~!}~~}~~}~}~~~|~}~|~ }~~~~~#|~{||{~~}~}{~~~~~{||~~~~~~~~~~~~}~~ 2lr +@,$Zk&82$ & +$.&26PX@,DT44h:0.'& &>92b9"/&>2  '!%  +).    0V,S \26.&,&"8$8<D,D4,@Dl0@@4404<<4,,X\4,HP,("44LL4 8<0$0 4b0$.$7%^:cguhHwcMZFG6G:9;.6958505.EpMz->B`4:2,:8($2H,HP0XTkbU[QM[GEA5:57?1=.";5.*%=2JtxA،ڐöЉ􂐌̔Рȁހɺ߸ߍ氢֠ؼȤШİĜ蜀謠АpذldHPh|h8`L8tLlxH4(`@P@Xp0HLh\8<4P @LX(0DD08pDju|V=B"RS,Xx@01FDNxTT\@,FV0rDlv@H`z?8(4tu~vol~昈ܗtȐɸſܪ앒ꕘ߉~~~~~~~}}}~~}~}~~}~~}~~}~~|}}|}}~~~}}~~~}~}~}~}}~}~~|~}~}}~}~}}}~|}~}|}}~|}~~~}}~~}|||{~}{}|~}~~}~}}~}~}~~~}}~ }~}~~}}~}~|~}~}~~ }~}}}~~~~~~~~~~~}~~~}~~}~}~ |~~"~}~~|{}~{}~}~-}}~}~}~}~~}|~~}}}~~~~~~~ ~~~}~482>>PX 5"qGH"  ,& F0 @(@4DXL4Hd, "2 ( /!*h;1D? %$)*2CP6DE #%#,  +G!4D*4H<4 80 (0D$8T808 P`@(8, @4@0$,,,DDHD84<@<,,LT<0,8 $HH8@0@H<""3"@t*4@$<(@4H\P08d68$ "$>&61.+I=A`HO|c:L3Sg0BE@AYGB@BJJSXZaosWUk8LQTDRD44X93379;(3008C0^9DP8BD.:TH@&&> @(4HPX0H\@8D,P`@(80$D8D4(000HHPD84<@<,4P\@4,8 $PP084H0D0HD80PD HDHPH@XlXdh\@Hx@dphpxh`xpdxЬxthh@XTTHp xpT|Hhp`Ȁh`h8`hHphxlhĂ|x\lxd|p\lTzpx80pdp|xpptl`xĠzttXz~zul8`t΢x`~fԈ∀8z|ph0xœ|ҘƆx̄Иt损zޜԦ谨|ИގlftRhlz\zʀtLD>8@@85>:-fsa|>lS7<=LJ=>?AOR> 9D:6>>44(*/C $yTNOX\42+%7'#$*NH'0&?"57%tbUR@SB拏̼̌м̸脂€ҸɀȀ߻țҀиȰ̔|tX`hPdt\XPLDpx0XTL08LpDlx(HxHp<dHP`KHNdI*$"G4$(t:P cI/vcP2cZS>|[.H8l14TV(&9%NB4, ,2 @4D<B! .K0Ek/02E )&!NI79BiIg>2(#(( + #-C$(*>T 680<$(H<@04@DD4,8,,$ 00,@TPD0(,0,@8<0$4D0@,,,P8MSQA9:143>+%7=:@*DX,DD<B0d$ $:<4HIWfHJUEHP^[E?bJ7:H5/(611+134oOPPN96pT4^0@N0,& lKN(Igo\^C\ VP` 4<0RT`6 Tn#tf(Vfd:,2\*;(Z,"2^N~j +"0,bc(B:P@8v\tĤlnĶ\|ƜœճҬꅤֆ~~~~~~ +~~~}~}}~}}~}~~}~}|}~~}~}}~~}~}~~|}}~~}}~~}}~} ~}||{}~}}{|}{}|~~~}}~}|~}~~}~}|}~}~)}|}}~}}|}~~}~}}~~}~~}}~{~}~~}~}}~~}~~}~}~~~~~~~|~}}~(~~~}}}~~~}~~}~}~}}~~~~~}~)}~}}}{{||~~~~|~~}~}~~~~(&* +.MB& $ 0$,(,0 0T`J@00(* +4 GZloW]GG>TB + - -/7U[kg9=bS633% &3 )C<,J`,4$40@$0,((/%PH82(. !!N28eL8*0:0:.68*"#76(4$<@PP@0DdpL<(82@ *0X-+2pG*x~}hdInjMD@U94HYD6U9PFqivnELkl3VT&SE+43<#D18P"AZQ*Ph0..d0@ (<&<2<,<@H88DP, ,D,8DTL($484<,(DD8@DH@84@@z]Z\DRf]bL[Cxv"< \,<,r,v0xJ24(TVȱ⦆(^~|xԊ؆옷վ֧Зآ؇退~~~ ~~~~}~'}}~~~}~~}~}~}}~}}~~|~|}~}}}||~}~}~~}~}|}~}}}|~3}}~~~|~}~~~}}~~}}~~|~~}~}}{~~}{~}}}~~~~}}~}|~~}}~} ~}}~}}}~}~|}~}|}~}}~~~~~~~~|~~}}~|~~~~~~}~~~~|}}}~}-~}~~~~||}~}~~~}~}~}~~~}|{~ ~~~~|~{}(~~~~~~{|}}}~}}~zy|~}~~~}||~} + + + *(*$  + " D<,04D@pdLT0 +# I MG@BGDFc?*AY[KB5!@C^nA. *"6) .2&&" &:!%$1+5%M:E9(2<8FB4@0$ %(*9,.,H8(8H($H@$(T@l`HP8,4"0D*9 *a-nUWKTU[vOJDPD=GHHE8HJ2bj'^`Sn_pRN.,pH8P@<,H<,(D(,@P@L@0$D< XDPL 4,48((@@,,$4<@@H@H:"08P@,(0,n2RX-Iw`(g:h@dB>F2P4 hvL](DFVEV*xVN+ $$uT9wC8(dsBbgB`lT`RV<DHP8pTq2D.ll}|ppxȬԸκÃꂂ~~ ~~~~~~~~}~}~~}~}~} |}}|}{}~|{~}~~}~}~~~}~}~~~~~}-~}}}{}}}|}~}{|{~~|}}}{}{~~~~~~|}|~|}~||}~||}|}|}|~}~~~~~}|}}~~~}~~}~}~~~~~}~~}}~}~~~} +~~~~}~}}~}~~|~||}}}~}~ ~{{|~}}} +~~~~}~ +~~~}}~|}~~}}~~z}~ ~}}~}~~~ + .(0 "$  "" (T<@0(b 0L>T0 ,C{DQ/UeXD8,@6##&$($1+=MYk^Oxb`vpd^*JO(.8($"q]?dr0lpL@$2*(8 B&=6T@,#59(K07B/,2zbJx@ozVH$<:HL($8L4 Rhk6;FiGihpcWPa*P"lV``&2DT`LD>40,<,(T@,080<<8<<$$48@@($44$,00(8@,<<@0,D,00LPPTPTP@PTppPXP\Xh@pH88pࠀȸ𘐤ЌМؔܰȌؤȈЀЀİРА̚ഈܼȪܐؤؠ̘̊ČܛܤԺкШ̎p֯Ц܁}ȌȶptxxДതȤ 覮~6A.(0!$<<$#-.'(!) 8@@@:0(*240((&, &&&/;HTRfJUq|^~BmpZ]>C?@BO=:LCFXwLlik}eZRd2V(lWbb* :PX`LBDHLt $00F ("(4,8(^.(XZQX.DZ8L, F~maRDj< $6=2>$TF%53.^_f +2B8B6GOSDfN`R=(<,`2\>T(vv `(\@X<T.JpȎبܼd:0,NphȈȔ̼֔ƔԄ~~~~ ~~~~~~~}~}~~~}~}~}~}~}~|}}}|~}}}|{|}~{~}~7}~~}|}~~}~}|~}}~~~|{}|}||}}~}~|~}}|~~{}~|~}|}}}~}~~}~}~ |}~}}|~~}~~~}~|~}}}|~'}~}~~}~~}~~~}~~~}}}~|}~~~~~~}}~~#~~~~}}~}}~}~}}~~~}~~~~$~~~~|~}~}~~~}|~|~}}~{~~~ ~~~$}~~|~~~~}~~~~}}}~~~}~ |}~~{}}~ }~~~~~|}}||~~}~ +  + +% +)! + + ($$4LL.%@4+4.+KudnU\T:D/+0JD'>D*8))"K.X>rErGLxhfxXjDtj`VjrZZhhlPP8hHNPF@@LH$0(,TT<44HD0@048D$0$@0,(4(040Hl|tpxڂ΁ʜ저Ма|HX4 V2?;;'* +8 -2,(782)! :9#8).*%00.84cEvDnBBncbdRfBpfTL`nXXb\`DD,dLVd TT(@<8 40@Dl~x|~wupvЈĐȨ`x8P\N\<>O>OH;12E@RR7WDBFE$~a{a8,@IJXn0Xb`\TVvj0LDptTJX,4$4$(0H0 0D44 D<4H8H@0<,@H08PRdp@Ԁxpjxzj|xpܜP0JZL\=HBP5>++*3 >(N098##&-:I_A?E96*:E2#;PEN?Hͷ¶ʷ߀լĨݝ鄀ͤ߿ìѮҤݼܼԄࠌ،𔄐蜨بȠ𐀠`xh@pp\`XH`P(XLT8@P0 D$tL <$phDP0@,DtthHD ,XPL,b,$(\l8xxTl0D(X(4X`<(LJNXTW(X4 xTX00 :L8I~48lzDVF(F~4LZD4@Z8.&<0@28J^lptt,xz&RP4F4ULseD4"K>:AI&=6,? BJ?`&WE<~K`Hl($T|ej,`|88vF4xȰ𒚂~vĄXX8*"DP\ $4X\8P@xT|@p򻣭򄅆мǺŰǸ~~~~~~~}~~}~}~}~ }}}~}}~~}}~~}~}|}}|} ~}|}|}}~}{||}~}~~|}~~}~}}}~~}}~}~|~~~}}~ ||~~{~}~|~}~}~~}~}}~~}~~}~!}|}~}}}|~}~~|~|}~~|~~~}~}}~}}}~}}}~~~}~~~}~~~~~~ +}}~~~}~~}|}~}~{~||}}~~~~}~}|~~}}~|"|}~~~~{|}}}~~{}}~~||~~|}}||~|~~~~ ~}~~~}~~~}}~}|z~~~|}}} + &  (aUPFapR$RK24 0*78)9'=;BqH:,YstX*jp׼ƼHdTp`^HlpT$X84\LLXH8P4,(,040P@PH4"&!!3.$(,068$$&"&&B.tl0, 2[Pl mN RWMaCRLLBC?;G=EA@_];CO5F>DqK<.YklR&ihЬBBHE77$vUúǼҿ쁕¤ʀƯˀٸᜆ˒֓辀ܐܚдĨШ𬐈Є،ИШȀذఐtX`Hl(`TH4$X@ 48 LDhdT$T\P 00P(HD8dDp( (,d4`4, `40T8|L t\T(D4^P2n6,*>Z(f(#@Z\`4BJBJ? ",89IRI8SI8G b DN8Lt0,HXhHP@ RN ~PXhX\F,XF`T4X(LjT 4XحͭʿҀ ~~~~}~~}}~}~}}~~}}}~}}}~~~}}~~}}~~}~}~}~|~~|}|}}|}}~}}~}{}|}|}~~{}~~~|}~{~}~ }~~~}}~}}}~~}~~~|~}|}~}~~}~}~~|}}~}}~}|~~|}~|~ }~~|~~}~~~~~|}~~~~~~~~}~~}~}~~~}~~~}~~~~~~~~~~~~~~}~~~}}~~|~~(|~~~{|}}}}|~~~}~~|}~~~,~{|}|~}}|~~~~|~~|~}}}~~~}{{~ }~}}~~~~~~~~~}yz~~}}~~~}~}~}}}~~}~~~~|z~|}}~1#  %B +&*4: +41u Ag?[ufXRc2@-!3 )A1RPCRLV\aranDR2, 4aVj{eURkHd^ChXDMB7.;?KF=E:DGXB\VEU\dfabZb@V:, `Ĥ|tp,(TP` lp<@(`XXX``PH<`X8@8XHHXPP4H@X(X8`X`@(`Phhp(8XhhXX`(`\`X``hp`PPhXX|ؘXlltpxHphxP|llld|rxtPtPptlxx`h`pl`xpXЄllxx@xz8\htrhppd~ltl0`|\vPtXxTxh@ttLhxhdtldtjlTf`v|ftXlxظz莔иĀЀ؀֔Ў訶Ѐ،XӺȲȤ|x`PP`|r~􈢒ʦԀ਼8T<]NACI.+&:!+,$0&&60+(>,2%CB@L0$,(20 4<<:,0@W$ ZH|m*rP L|0<<(t,8X04w! /g`W3B( +4 +2J +$ 887E8/' + +,#7!,# d\PV;A-K_:0?/1/G<8U01D1[CiJU:BA=7( @<$.-4*",.C>TV641H0I3&KiiiSo^J[YG=J8?=;=;?GFC;?FW8]P0N`RR*F8sdJ@J6<0xlDsHx8H8l0b@(`hhhX`Xh`hh`hxhH8@HXHh`0h`P@8@HHhpT`h0HpXhxPPxxHlh0XpPpX@xXX`PlPР||Xpdp|t@h|pxxxp| dlxlPhPtTlxh`h`hp`pLhPPhd`x`ThppĔx``dXdT0p8dPtlXXXhPthXXhΦ؈rHxP @PppfxdL`LnxH8p Dx|XhPt\Hh@``xĴ|@xȘؚРȠ֫а֍Ĉ¬ȸ䘥@ 且X`ύТĔ`xtx`ϕx lĖ쀐tز`LX6CZf9&2A(',%'+,LH?MB=>>#D=P9D(%8zgelYνʀఒٻ¾ެⓟȳ̴ʀلĚ杪܈̺ȰȈЌȐШРЀࠈ| Pxl`l(XL`PTLp0(\(0(8DH(<$$t8T(< 0XL`4 <pL($ @0 ,Hd(`< Ld8H(\8 $ t,p(`,d,TDLPL Hd0D \ +,(d0@H`tRZ(Z@4^hHxDxdR8 "(x\0`iDDZjJ\ldfd0Zrt\HXddLbRaZpdPpLN0yp< H0K+Mj&" +rR|H@0Px:xxd08^P@$DX1V|,00𐼬΁ѣǵ¥߀~~~~~~~~~~~~~}~~}~~~}~}~}~}|}}}|}}}~}}{}}}|}|||z~}{~|}~}~}~}}~}}}~|~}~ }~}}~~~}~}|}}~ +}~}}~~~}}}~}~~~}~~}~|}}}~}~~~}~~}}~~~~~}} ~~}}}~}~} |~~~~~~}~~}}~~8}}~~~~|}}~{~~~|}}~||~~|}~~~}}}~|}z}~z~~{~}{~~}~}}~~~~}~~z~~~|}{{|{|~}{~~}|~}{}}~~~}~~|~}|~~}~~|~~~~|}~~}|||~~~ ;  + !2("  + "*qJDIJ'SbqDT27 #*-I^>>$ &Tp`l2l:BCBHHII46>,5NcP.T820Xq^f.xHL0CE/ !('0:V54>@JH:5.>U:G-LT$.Zд׶Ѭͺ¾жˌѥڀҭвڿشʩԒ܄̀ވظиظؠਨظȸИȰ𰀸``xP@@Xpt@0pXt(hd8P$TP0Aj ~&(~8mp8D\4R4|$HZl<0 f `DHLPJKB$24$^A@6e<.$ Ьř䠬䀀ꎸ~~~~~~~~}}~~~}~~|}~}~}~}|}}|~}}|}}||}}|}z}}}~~~}~}~}}~}}}~~~}~|~|~}~|~~~}}~~}}~}}|}~}}|~}~}~|}}~}}~~}}|~}}}}~~}|~~}~}~^~~|}~|~}~~}}}~~}~~}~~~~~~~}~~~~~}~~|}~~~|}~}~~}~~}}~~~~~}}}z}~/}|z~}}}|~~|~}}~~~~~{}~~~~~{}}{}~~~}~~B}}}{}~|~~}{~~}~||}}~~}}}~}~}|~|}|}y~~~ ' +   + +  '+% "4&wK)5##0VF))WQ-NIJJ?>2#)'"DB9d+407)/+,:IOJ+-#2>,8&|T BWL>5EM4/7\I}t{]3rTLBL364@B=@<<:*B76T8`ABF@\0gLVh@646HP4`|p`hTDLhDTt8X``XH@HXPhX@@XP0P@@HPPXhX88pxP@`h@XpxPpPhPPHxh8`XP`P@`pxh0p`p@t@ظ0`p0Ppl@`x`hpht\`|`pxh`pp\|0Xx\̌@xlddtd`X`8Hxp(T0(xXxlxpzʤlnjtĐ`tXhȄxxd0\pth\h`xh|xpPЈΤ|̥vИPxĜz@؂萿̐ȦؘШಀxдĐ`ĦЖpppؼh̜ܰ@0$,Dp@CiX42L&JNL-:.TB225+;^.92@ɴ׶ɿڹŽո∀íȞȀж؈̨젘аȸȨ踨Ƞ𰐨Аиࠄ@0`x\Xp0``P @h8(8(h`PH@@@\d\$(@D, P@Dh(L4DP4|<<0l@(t$`T$(8(H@VH<$LDB040THd$ h$HRNz(v$j^2:v|48VD(<FjB$aYLkHUl(H8h XTLP>08U~r?T8, b8 .NZ4 JPD0 pg`DDBPLQrtp~4L`0 xܰԤֲΊ }~~~~~~~}}~}~}~~~~}|}|}'|{}}||}}}|{}}~}}||~|}~|}~}{}}}|}~~~}}}~}|}|||}}~}~|~~}}~}~~~}~|}|}}}|}~~~}~}~}~=}}~}~~}~|~~~}}|~}}}~}|}~~}}~~~~~}~~}}~}}~~~~~~}~~}}~}~|~}~~}~~~}~~~~~~~~}}~G~~}~~}~}}~~{}}}|}~}{~~{}}|}}~~~{|}~~~~~~|~}{~~~}}~~|}~~}~}}}~~~~|~{{~~~ }~~~~~~}}||}|~~~}|}~~}~~~~~~{||~} +~}{~}~~~- + & + 8 ! 8'7)7>("$2ELi1&M"9>/ +  > Zd8PB~2]>&JkZ@ 20(Dt8xhtH\p@pp@PHH@0(08HX`PPXXH@ P`HHPXH`pXHX00xx@HPpx`xHPxPx xphhpĀ𠘀Ęਘ؀ȘȤа𠐐訠𠼜м蠈Ь̴̈܊Ф􎠀Ȉ؈Ш܎ȇĐóԴؘ`М vp`~x{r԰v􍇞0HҖx`Ԍ֋֦Ѐpغ̠מؼ`蘐ഀXXX8&n0dI50HH83,96JT+ 13+*$7>.FK=VTc&+)+*K@XAAhp>H@ENSm:F~a2VW2[l+48;532/6BE8\00ahL@8L4`X(H<HZ@0(X@ N,88 hLkN`(D>80Hx,,(.HN.>P"Sd08.,L($R$ yd`u~x @bxh0HXHDd^P&P"`L?:ZjxX@5Tcv4XYT4^T2<d:Z`~rtZ~R2N>^P V<,Hltl~&Vk4\o8PFfH\d:NP8H\$X֤ƮĈƄ̠~}~~~~~}~}~}~}~}~~|}{}|||}||}|}|{}||}}|{~{}~}{~~~}|}}~}}}~}}|~}~} {}}{}~|~~}|}|}~|}|||~}~~~|}|}~|~}|}~}|}}~~|~~}}}~}~~~|~~}~}}}{|~~~~}~}~~.~~~~~~~~}~~||}}}}{~{~~}{~}~&|~~~~~~}}~|}~}~}{|}{~~~ ~{}~}(~~~|}~~}|}~}~}}}A~~{|||}||~~|~~}~}}{}~{||}{{{|~}~{}{}~}{}}~|}|}}}|~~~ +    '   @  ( 21PUeye;,&)%.2  *%;+"<@hND"8B*H28Jev`D\P(:t)43/,-2;B:9qN##,M6@gLE0/YxRf~gBPPI]O\WH:H\-44/90XH@P|T\PxR*lnT5R@[ZD[, +Zf]LSP:XN^HiZhjZ\8`62N8[d@J0_v8MjbRmpTP(yXJI^>",*wB +8^CW=8`[bFJ{r"\R^/@tj@ p̆Ԉ˹܈撼ҒЙ~ ~~~~~~~ ~~}~~}}~}~}~}}~}||}|}|}|}|}||}}||}|}~|} |}~|}~}}}~~~}~{~}{}{~|~}~|~~|}~}}}|{}}||}}~~~~~}}~}~ +}}}~~~}~~~}~~~ +}~~~}}~~}~|}}~}~}~}~}~ }~}~~}~M|}~|}z|~|z~~~|~}~~~}}~|}~|}~}}~||~|}~~~~*~|~~||}~~{{|~||~~~}~~}~~~~|~~~}|{}|}~}~~{}}~|}{z~}}~~  "$0"    + *<",6%fP@vswDHP; +11)2  5*  ,dWND*nJ."2@DL>hv\LT` pdhPp`|@``tX\`\TXdȐptPhXXXP@H00PP8@h8hX(hp88@HhpPP`h@Hx@P`xphxP xجЈШİؤȘ𨈰ఄȐ輘Ƞ؀ԴȤЎ̨踘𐐸ԐԞĤĢָ̰̌И`ȸؐࠚph|ʹv̸@P`؄|Ȁؒp҈кؖ@ܘǕĀ{{NȭĠꀠąĜz舀|`($EA?* &: d4*6)=_S;.-.169B.(,4V^L)6 <%)RSQ(BU<<3-30.00/03543]j;5L49ak-P&~Z?knyJM^];sjXPF4tP6*f|jTTpt0td\@p\8vr(H8h@XhdP@Th``XpP`XPPH8@((HH08`Hxh( `h88@HX`@0Ph@Hx@PXppxhP` pphp|@hxhP`PxpphpP`8@`|Phx0(00tdl0XX@pd|tvdTlt88`l\Hx8XhPhxtrȖtpHpxPd d||zԬtxxlxhPt|lXdz@lH̰¬h|`P|МĐЀȈԌ΁̲슕؄ܔ҇ȴ̐訰̙Єpв̦}ppẍ̢ư𘠸ఎpxD,.9YU R@6%/!0)L$0vF*BTD2rK@>43=:>A7)+EYB:3 26*C_VJ4B7>;/7,+10-.14671F*Tz?d~JDXm]7b>mr\}pK3MGEDI22b1"+*66r^H$:4B4(J>$HT6Tx<8ld^tph4Xp 8T8X``xP`XHh`hhPpTh8h0`X@H8Hp`Pp`X x0H0PXHH``HPh P``pXxpxp`px@ppp```l `lHpXhxlth\xx @lxxhȐ`@Xlddhdxt`ln(hpph\\Xp8\\ДlxTl\p|H`vhxpdxXj`pdTXxpdspTxtrpllܔ`xxXPx|x`@ИРpt||Ԁ̃Їجʜ؈꙳莖耐ƴʤ܌ܰ̂ҤҬЬDܠܑĈ̐򔪿ȸƴxh0X0$lC:<5%,-2>B4N8Hd,b`,ʳ Ź؀Ķƀ〺ǫͯћд舘иИȸи𰰠ਰР0`x`dH @ \HxX(($,4@$Hx( LxHxhW488H@ 0  h XD,@0D(D04XPH4XP@@(@l`0@LT8 :HR`^L,<$124V.N@P>TtzasjM~8:\lN0jh bw0$-LoBYC) 00@ T`Фʆؤؚ~~~~}~}}~}~}~}~}|} +{}||}|}}||} |}|}}~|}}}||}|}|}|~}}||~|}|~{}}~}}~ }~}~}||~}~} ~|}~|}}}~~}~}~~~}~}~~}~}}~}}}~}~}|~ +}}~~~}~~~~~}}~}}}}~~}~}}~}~~|~~~~~~~~}~}~}|~{~|~~~~{}{|~~|{}|~|~~*~~}~{~~}||||}|}~|~(}}{~}~~}|~~}~~}||~~{~}{|}~}}~~{{}~}~(  -9, ,  + + $&75& 0O'>XU`M=<=QL -'b3aF#52&,E/"A4b>$42 ,((5B>::%?T^E4A9?95Q?=K;L>2;942,*1%22.7&L:KK=R-fUc/!_XgYSTPed-IkSdEzEo`!.2>OL3@F[A6F4dH2*B>&$4*0:THXL\`tȈH\|Hhxh\p 0hpxP8``HpXh`PX0(H00`@X8@xX HP@HpxP@P pX`hPh`p`Ѐpذt|@PhаppdtdT̐th@@\@xPxtph\|x`x` lTnd`hp 8Lv\|td`xX`PvHH`TX@h x`xАHrft`X`thXdH``@nXt|\hpXШ؄ܠ܎ދزΖltȰ@plȘ臖਄Ў܎žẘآʬخ蟯ҙԱ򀝏כ잪āиҼԊҹ؈ȰШȀP@F;410,62.63&V@D\KU$/ûրـͻ߄񵻆֊р޲㄀יʽֶ֝Ԋ̼Ќ𰘐ذȨȰȰȨШЈАА耠px xHx pX\p\D0|x4j(LX(4p|xLL0T@<#BZhT( (.$>6\hzP04$fn\`r@H\|pl8|dXpx`pxp 8H00H0 P@HHh80H`8pPh``plxxxxx`pАȘ蠐ԜܜؠЊИȜ쀔Ȭ耐ࠨи蔌мش̒Ȅ肎ȖЖसܾഴ¸ЀЀİ؆|x𸘘vz0@`pppĘȞĒب󼝵Ό̞nȰґ|tv†x`Ъڑɨ|{`ƚ؀Ƞ@@H0D@:7-(%.S=@]M133N- - 1 #3@>/CkPJ[ibQd%E7$IT9-:88:$dt"fT(,4 002*B:\hxP04,jp`X|n 8xTtthd8|dp`X`Xp(800H0H PXXh80Hp@(`@`pXXhhxpppxx`pАИ`p`PPtPphXXx@p8X `|Ąhdllx (t@xXpИ| xT`|xd`8xp 8Td@HlX`8\XPX`|d\Htx\ȴzpXXh \tv|PX~Tl`@hX`pļdfPhttn0ȨXp𸸘x~|Є@`ꠀʄАސ̤ΤД۸¢Ќ͟ذԒƯԌ䗣ҲȆƃʄ􄲒ʚȀЀаdT .T`-GD4$<73(H_HV ,D4§ރ̿ကǶܠѬ׀ٹ駳׀ݠ̩ض𸈘ȸШ؀ظȠРఐȘЈИА࠸pP`@Hx h P0`hH((pl|HHx0l0l, P@@,P`@\(VH|Lp\0@T8N@<\$P@L&pblX b^2nnDB(80h\ 0|d@ +QAI$&<`NlNlDFU" Tf.B@D\2*<C78/G.LI317),<, 1D#K84BQa[O@1,/20.42.09nFf`U=hrmO)MD_?EK@MO^XHOIM5Na2H6$:JP.J &0N$@,HDPHLH>h`d4dT8(t\P0Ph(HXpthX\p@P`H\X0((Hx`8H(@PXH((Pp08xx@Px`pPphx`PxppЀ`xphhhxp`HpHlx0|x\n@PPtxTxdxtdxpHd|T8TT`dHP|hpĘ|\tPhPH@8HXXp`ttXv̐P|tИ8`ppxj`rHhPPX@tĨL|nh\lȰ0h`ļp0H@Ȧ܀،ܬ`@ĜƂ̸戀ܿȴȒdžn`ҌޝƪДΰЈժpؽǰh`ȐȊ̬@<4T"LXZF0lD:,X8Xϱ숃ָĿŢȀɀ׼ȳآ޴̈ʤԸ䴜Ĩؐ脤ȈииР࠰XH@@Pph@H8H@`P@px 8XH(0(0PHDT\ <,(9^ L$<PPDV@L@\d(hH`hdPP4,|XD(X<D4L,P $ F04D@Lp<(<(,l$@@T`6B\,0$( < dH:0DHp4ZdlTTp>PJ6d^:j_TL8@^F~<d<tLU,6XW}id$tLZ2Vx6n?1P_4BD R>; >@,d*DPRHf^ ,JA^8BY :?eytU80Z74lh:QD.,&I'$$0nz%0`@@D^e^nttIJꏺ궜 ~~~}~}~}~}}~}}}~}}~}~~|}|}|}|}|||}|z}}|y}}||{}~}}}|~}||}}|}|}}}{~~} ~}|}}}~}}~ }~~}~~~}}~~}~~~~}~~~~}}~~}~}~ +}|~~}~}~}}~}|~}}~~|}~~}~}}}~~}~~~}~~~~}}~}~~}}~}}~}}|~}~~}~}}|}~~~1~~~|~}~~~}~|}}~~{~~|}|}}~}}}|~8~~{{}~~{~~}{}}}|{}{}}~~~~~}~ ~~~|{|~~~~~|~?}~}}|~~~}~~~~}~~~}}}~}{}|~}~~|z}}}~~~~}} 1:(#'!& 66=B0?4#5\IR<&9M4*?*W48B.rHD< (>@*$"0&8@XdF\(DDTlXHxt|d``|x`ptȈhhXxpXXHP@xp@H@P8H80@PhhppPp`ppppࠀжܐ𰈌谘̬ĴܸĐȨب䨨ࠐؘȈВܬМ؀аȈȀАȈؠ̴Јؠ蘀Τ䄂Јиༀ̌􆇽ҷԈΐ`Ħ}점||\xҀІ܀⁀ؐxtأ̃xu򄀡ȑ`芃Þ|ŀ᫃Ԕ~씜؁pТ̕Ȅӌ؈pࠤp lD <0@h.M4/'3+S\:&0-+* 2/&&"(6&La?('3QQ?IP7G0?@.98"@3cLilXeR=Mq4a_K87<9>E(-5xmSMRZ>iFG P<|TRJ,0@4(.,:0L$PdpHd0HH`x\PtptxXP`|lT`pp`P`xxX@H800xp@H@P8H80@PX`hhH`X`hhppи```xPpx`@pPP@0pHptȰ̈H|`Tl|xtTdX@ltH`hPl@HPpdx\txhXzT\x0t|P0h| `hpt`lxd0PXpxthhhxpXnxrjnjl\tX̬ptܴlʱԊБ򸨅𰰄І~䁀ؐ²ত҇ȲƏ܄􈆇ꌆɤˀ卵㬃|ҚȔxЀȀА`zԠh$`(8X@PƷحࣜзؾϼǯƺ̶倀Āڀ۹רྲĀȸؘĬന訰̘Ȑ𰘐ࠈ蠐ИxਰXHXh@`P00(x`Xh PX0(Hd$ <$,h<00L(NH<<@( t\,p4TP:X6"X|[`dpB,~TYlD)0cM ^^xxFRtECfWG&T_PY<L("HAJ8+br[@kU1\\9I,6`(Kx\tz[.0Y .48fB&8z{$040B8`vX@Шؘ ~~~~~~}~}~~~}~~~} ~~~}}~}}}|}~}|}|}|}}|||}}||}{||} |}|{}{||}}|} {}}}|}z|}}}{}~|}}|} ~~}}}~~~}~~~~}~}~~~}}}~~}~~}|} ~}~~}}~|~,}~~~}|~~~}~}|}~|}}|}~~}|~}~}}~~~}}~}}}~~~}}~+|~|~}~~|~}~}~~}}~~~}~~~~~~~~~~~||~~~~~~~~}~}~~}~~~||~~~~}|}~{~~}~}"~|}~~~~~|~~~~:|}}~|~{~}~~}~~~~}~~}~~|||}~|{~~~}~||}}~ +}~}~~}}|}|  '7  + $*#*IHE;$(ALI80>?!$$(6>6-?*^Krx,< 08N@(<4H0(<6TL(tlHd<@4d HDX$$  8DL$pt80H x (, Lx|\(PL8X@@@P(88h$ (@0xN ,0\P@H>t(l ($,DP@ $2 2 8& H, @P<Xp(6r4gtGVr@HF]40J@X|p,8}>vl&*RSZ@P0DX6Mc8Il,.~ +=ebFnTXe]L dfVV~lO$:PLCe=b`$(X,Y`8^hll2hMx_DD2/NqW>D$lV0],hvbȀ@0@ ~~~~~~~}~}}~}}~~~}}}~}}~}|}|}3|}}||}}|}}|||}}||}}}|}}}||}}||}}~~}}}{||}}|zz||~{|} +|}|}}|}~~~}~}|~}|~~~|~~~|}~~~~}~}~}~}~}~||~|} ||~~}|}~}~{}|}~~}~}}}~}~}~~~}~}~~~{}~~~}~~}}}~~~}}}~~~{~}~}}~~~}~ }}}~}~~|~F}|~|~||}}~~}|{}}}~}|~|~~~~~|~}|}}~~}}~~~~||~~~~~ +~~~[~}}}}|{|~~~{|~~~~~~|}~~~~~~}~~~~}}~|}~||}{|}}|}}|~~}{}|}|}#41 '8' +:B09'7<=&APC&0N*# "-09!*TF@8D,4bP 0HP(@H$8TP`80H4"$H=q6 <$X>P`(<$*R\$8HZl.XyJO4jXVPPX(`D lDFDTP<HK0>0dX2T LB&b XLH<rR +`Ȑ@P~~~~~~~}~~~}~}~}~}~}~}~}{}}}|}||}}|}}||}||}}||}|}}}|}}}|{||y{}.|}}}{zz|}}||}||~}}}|}}~~}~~||~}|}|}~||}|~|}|~}~}~~~}~|~ ||~}|~~}}||}}~9}|~}~}~}~|}}~}~~}~}}}~}|~}}|~}|~~}{{}|}}~~{~}~~~~}}}~|~~~}~6{}}~}~}||}}}~~~~}~~}}~~~~|~~~}~}}}~}}}|~~|{}}}~}}~}|{~~~~|{~~{~,|}~~~~~~|~~}|}~}}~~ |}~}}~~~/~~~||}}~~~|{}~}}}}~}~~~~~~~~}~{~#}}}{~}}~}|}~zz{}}||{}|}|}{|{{|!3 E' !* *& +$>R&@1=FQB'23K0(#47'/%$, ) "xxTt\VPPT(H>$,,@L<\ @ D\`8PT4hhtĄPphp舸x|Аx`|p``hph`@PxhX`xpx`Р萰Ȑ蠈̐ذȀƐ؄؄Ј༤ؔȘ쀀ȸܐИ𠰐ऀฐҐȌب而ИО윶Ԍ̨舰踤ܨ䠶𒐪`lԠ@ Ĥ@X tހ؀̔v䘔Ĵx欔‘̉@pxpЬʺи~`@00Pظ丰 xh@ఈĨİDS7&B_C-;;%CsS-4 .',/23G=lE#1;,(A?'%3))%=Rd9[?YalZ0;"H>d>7FX[Q`ZISAF39NBdN"60$Ld@tPLT`4PB,4< P\Lh,D$D`d($@@T000H8`8@(,(($0T\D(<@HH\HPPt8H`HPTNL$T LD\$ H dD(,0 (8@4L44(>,rtVX,L 4JX>CM;TZTMaK> ~kdF&+au]:'&$tS|D, xH^T88\D0)9 fY: lT(4@X(0(.,D,P<,,D,X:\L DMD6N&.>]2j XFV`TtndEDNX80Tz$PMZTGP 8HV@xH\@ahl:w^ ETo..,N>B9Z. J;/B "@Hb8,W0g@XLLRC\HFNBTB[B*s@^ @$,P&.K=""+!  #'"h,hX|(($R(040($(8l4`DPPp@ࠐpxȐh|ppPp`p`pp`ਠР䤈؈ШȨ؀иȀԤȘؔ䴰ȐЄи樬ЈȄȆ蔘살К耘܁ҏ䔐pxȀР޴䠬|༈蜜}Āܫڛλ踼}쮃Ծ䞜pĊ|pNJlP|`z`h@pԸڑx䈝Ƹ޸РpĀ ਨ=CJ6UB=4W2;ZiWa;5)./HD!J9Kj9!:*:H,H$+JsO3=-d8>-WD268<191:+DAK=B%FV8(,ff l-1K\]^28rx8(L8i((Tf2""G:Ne64Qpr0 V|$Y.8|>H<X}PNK?bz_RR7?(JVhDVhd@`h8P ~~~~~}~~~}}}|~zz}|~~{}}}|} |}|||}|}}|||} ~}|}|}|}}||}|{|||z}||~}|{}{{}z{|||y{}}z}}||{}|}~ +}{}}~}|}}}~}}|}{}~~}|~~~}}~}}~~~}~}}|{}}}|}}}~~|}~}|}}||~}~ }}~~~}|}}~/{}||}~~}|}}~~}|}|~}}}~}}}{}|}|~}{|~~~}}~~|}<~~}}}|{~}}~|}}~}}~}}~~~~~~~}}~~~~}~}~~}~{}{~~~ |~|{{{}>~}~~~|{}}~}~}~~~~~{~~{~}~}|}}~|~~~~~ ~~~~~|~}}{~~|}}||~}~~~}}~|~{~z||}|}{}{{~}{|}|}|{}~"&  1(L'  E4!9+fE1@(s9*4nS #  ! D60@X0Tj4X$4<8004,$dpbL`HhhhȐx`xpȀ`x@xpЀpਈఀఀȐ䠐耀ؘ𐠐ȘؐЀЄиЄАЀȐЌ؀؏ఠȌȠ̰؀ب𠘁΂`hx঎ް|p~~Ȑ̌줈质̖|̦|x~{ґ~x֟伊~ptxАvz}؄x|~ڼt|~xȼШx@L=8YH<,HnYT\BG6EDL>@P$c<4Hxxp VlH( X~}~~}~~~}~~~}}~}~}||}||~}~}}}||}}}|}|~}|{}}|}||}}|||z|}}}{|}|zz|}|}}{||}{|}|z{||{|}||}||}||~| ~}}~~}{|~}|}~}}~|}~} +~~~}}~}|~~}||}}|}||}}||~}}||}|}~}}|~~}}}|}~|}~{}~}}}~}~}~}}|~}}|}~~}}}~};|}}||}~|~~~}~~~}~~|}}|}|}}}~}}}~}|~~~}~~~}}~~~~~|~~~}~)}~}}}~~|~}~}}~{}~|{}}}|*~z}~||}~~{}~~~~}}~}~y}}}z}~~{}~~~ +}~~~~}~~~~~~}{|~~~~~~~~}}~#~~~~}~|~~~~}~}}~~~}~~}}}|{{}{||}}z{}}'%#  QKL "5 !BD89@%1 +%p]^"A0(3)Z+#40\H00(,$l<804$4<<<@@<\,l@X8`8Xxx ``pphؘȐ𰠀p@ЀȜЈЀȈؘظؘȼظаจఈ̆ȠܐИȘАȤ̰עڐ̠؄ఓаذx쨀؈Рֶ̘ЌЈ֮ޓЀp£~֦~~Ȁ伺}ؗʼxrڔ̞rlxl ĞŘhhԖxРޚޜxpp`h ؠxtxؐ5(QRGQSexu8#.10h@%GOLG:VPEIa;G9M"A2=rq3Q?,)J2-I4*:Lwd!$(85fR|@DD},D" F8E0V$GgFZ$ `&P HO&dC76J +` 4T(4 00<8HxTjDD~}~~}~} +~~y~~~}{~}&||}||}||~~~|}}}~}}}||}|}}|}}y}~~||}||{|$}{}}|}{}|{|||~}|{y}}}|||{{}}{}~|}~}~}}}{~}~~}|}}}~~|}}}||}~}~|}|||}|5}}~}}~}{}{~~}~~}~}}}~~~}~~|}}|}|}||}}~~|}}{}}~~||~~}~:}~~~}|}}||}~}~|~~}~~~}}~~|}}|}~~|{}~~}~}~}~~~~~~|}~~~|}}~~{}~~}|}~~~~}~~}|}|}}~~~{{{~~|{~~}}~)||||~~~}{|~~||}|~~{}~~~~~~|~~}}~~{?~~~}~}{~~{}~~~}~~~|}|{}~~}{~}~~}}{|}~}~}~}}|~~~  ",Q9 +'=%-?rZA.9+=2%0 F8M*'8'F  +"+ '&&T(8D( @(,D8d08848<0H48@0PdhXМpPxxhxhp@Аȴ@pЀЀ@pРxpఠ؀ЀЀؠȐؐ谨؀ЀȈ蘰شؤа褠ఘЈ쌀АԜШ̴Ш𠬬О찌Ĉ삐Д֟ѢȄب|`蠸̨`x蜘x|ֳX̆®Њ~⤾po抒ĊxP xd̲x0 v``~p ĨH0Јx|~x|܀ȔΌؠȈĀ;1!27:]@>-;I`1)%!Jr\715Qf9EM/;^Z|s^P^?P^( $8b\Ps08 lZH (JP,@PPX8hXHxXTPd`@ RTph,D($d($8h<T:e v.OpxN4"XV@`ltWv.4WF $ +rD p.m8j^.&F*6"G{Df8jpJB @n/|j]^@PX,8O4<9;0NTXCAJEY " *#(4ce~LM=)Aj(0@@P48hDHT0(@4(0,,4@<0@D8```X `ptИ`I``Pp@hl@@ppp`@@pxp`hР``А𨐀@|`pP|@`xxP X tHh0`hpP`x`p``php@p`@Pxllhth8\Ȁpxhppp0x00`Hl\d|h`h`8TpTvh`@|h@hxvHfL8PXP(`Phht@dht`hj`|t(Tt|hԨ8lhd| ☆ʼ@{xߐp@𘬀0Р|pX~耠Ȅx؀ʋĘذԀ󭒓ꐰ࠼ؐڜĔ謎ȼظش씔2Ĕȶؼx`𜌀t洀ϫüƗɱβڱрνĴ𤤸ذĔ耀Ѐ``xiبȠx0 xhtl0```Аp@p@p@PX0`pl`LlH0r x@P8P!LPD X(((`X ](XPD0($XPPB h@D (P @8^@H(>H\P48t8P4T,P t0HP0@T0`(!P>^4T LJ 8Z HX1<\FQF|XQFok_rwpdd^vPp4Jl&TC$h00XL0pT&$K[24X26DL`aI=PTf0Oh..6Rhd0Dfa:,4Ea>|gt|*Z:$Z::]I H"n@PhpXP8X8$ Htd@0xpPh~}~}~@}~}~~~}}~}}{}|{}|~~}}~}}}|}}}|}~~}|z|}||}}}|{y|{}}~~}~~|}}y|{{|@{|{}z|}||}|y}|}{}y|}}{{|}~~|{~~}{{~}|~}}|{||}{}}}~~}}|}|~~{}~|}~}{}}}|}|}|}||{}||}| {}}|}}{~~~}~|~}~~}|}7~|}|}{|}|}}~}~~~}}}~~}~~}~|}}~}|~~~~~~~}}~~}~}~}~}}}~}~~~~~~|~~}~~~}~~}}~}~|~~~~~}~~z|}~|~|~}|~~~}~~C}}~||}~{|~~|}}{~|}~}~}|}}~~~~}}}~~}W~~~~||{}~{}~~~|~|}~}~{}|~}~~~|~~~~~~~}~~~~~}{~||}}|}|}~ |}}}~~}|~}}~}  ':?!"&"'-JQ + 'D&(7$ ',<" @,@R8$@$4HHPXPX8LLPPp@ xhPphh`p `؄𘰠Ѐࠐ̸ȰиаࠐиبȄ࠰訸ΦШȰؔИ䆸оЈȀ؄䐠ԠİбĆ`Ȁ𠀸؈ࠀĶȜ̈x؈²xxh`Ȃ⋆򼢲n}zО~ƴp҈`Ƙ~Ѐp0ȐX0Ќpy0zzಀЀภЀਈ6>6/9NTWN02UVagN?C9(WD?\.=D5J5Wdh/-QD24B7;:'"GL['!NhOBZ<xl"d.j]XjbL&M( |0TpXhBJDp-: <:@VLh~D*Q1(>>|ftXKBO`VJ33?EON"106L4 6FW^+*$!,"-O'XIcB/"@T@LPX0(d(L$<8HD pP04@<Ff ^04,DX*`hi @`8P88hh$, 0|`rDRu 0VNfUP)h4f( ~jhN>EeL1P*H&I=H,=TV<pP$hhh`@@h @P8~}~}}}~~}1~~}~}~}}|}|{~~|}{}~|}}}|}}||}}}~{}}||}|z|}{|}|||}|{zy|}}|{||}||}}{|z}}||y{z||}|}(~}|}}{}}z|}~}}~}~}}|}~|}}}|}}}~~}}{}|}}~}|~~~}~|}}}{|}||}|}||}~}~}~}}~~}~}|}}|}}|}}}|{~}~}}~ }~}~~}~~ }~{}|~}}}~}~~}}~}{~}}}~~}~}~}~}~}~~}}}|~}~~}~~}~~}}~}|}|~}~~~~}}|~~}}|~~}}}}~~|{~~}~}|}}~|~~~~}|}}|~}{|~~~~~ ~~|{}~~~~~ ~}}{~}}~1}~~~~~~}}~~~~~~z|}||~~}{}}|~{||}|}~}|}~|| 0  !'/+  +"&*ID'4376">D< 1Q  + " +)Vs2@s86YPea8B5ST60NTP"#4Nv93'#:B,A=.1G$#"?HH\X<@ HH@4$4,4DPXLPDD,xd4 dPRp|$,XP4 pd80T|LND 0F)\$8,D 04h8H"$ $@lN(N\n<$NX^|pJx<(@2:Nbq~"MR on8P (R jP2Jfxl RdNT:~^Rjh(B.(]:lr=Z:2R>n2LM{dT42<8"D$.bMf B|Q:J0$zH,p-N` L"f<t x,0 8p`dp@0~}~&}}~}~}}~}}}|~}~~~}~~{|}{}}|{||}|}}~z||}~{}|||{}}|}}}|}}}|}}}{|||{||{||z}}|{K}|}{{|||}}|}~}|{|}}|}z|}}{}{}}||}|}~|}}|}|}~~~}~|}}|}~~}}}~~~}~~~}|||}|}|~} ~~}~}~~|~}~} ~}~~}~~}}}~}|}}}~}~|~~~~}~}}}~~}~ }~~}|~~}~~|}|~}}~}}~~~~}}~ }}~}~}~~~}~}~}}~~{~}}~~~~~~}~~}~}~}~}||}~~~~~~~|}|}}|}~~}}}z~}~~ ~~~~|~~{~~-~~~}~|~|}~~}~~|}}~{}~}}}|~~|~|{~{|}|}~~}}|}~||~|}}|}}~}~}|}}| "  +%*!+16&+0&"$:D:26"H :.! H( %5N3&+ @$8(DL@ ,HH@^F9L^TX?RmZM"+ 0QMOb\| {:X.FHB@8hn|p 4PD@x@ ~~}~ }}~}}}~~}}}|}|}}~~~}{z}|||}|}}}zy~|||}~|} +||{|||}|}|}|}}}y}y{}}|}|}|{|}{{| {~}|}z}}z}}{{|%}}|}{|}|}}}|}~~|}~}~|~}|}}~}}~~}}~}}}~}}}{}}||~}~~}~}~(}{|}~}}~~}}|}}~}}~||~~~}|}~~}}~}~~~}}~~}~~~~~~}~}~~}}~~~}}~|~}~}~|~ ~}}~{||~|~}~8~}~~}}~|~~~~~}~~|}~~~}~}~~|}}|~~}|}{~~~}}}|~"}|}~}}}|{|}{}}}}~~~|}{~~}2~~~~}~|~}~~{}}~~~~{|~Z}}~}~~~~}|~{}~{~}~~}|||}|}}~~|{|{}|{}~}{|||~~|}}{{}z|}{||}|}||}#! &'''/& !';2)"(356(&#@,|(R$<$ LPTL4XD@Z4@8F4B T. n@d04 H|0>PzUP8@D$jDPTLL(tpX4RHZVH\tJ>Zd<`@,}mLx6 BUgd\2RNdbL<^fH`hN4@tt @4r @P ĤV``pp0}~}~~~}~~~}~~~}}}~}K~}{}~~~}}~~~}~}~~y{||{|||}}}||}}|{|}}|}}||}}}{{}~}|}}}{}|}}||}}}|{|}|{||}~|~|{||}~}${|{z}~}}~}}}|}}}~{|}{{}~}}~}~~}}}|} +~||}~~|}~}~~}}~}~}~~}}~|}}}|~}|~}||~}}~}}|}}~~~}|}}}~}~~}~}2|}~~}}~}~~~~~~}}~~~|~~~~}}~~~~~~|~}|~{~}|}}}~#}~~~~|}~~|}~~}~~~}|~|~~~~~}}~!}~|}}{|~~~}~}}||~~~}}}~|~~~}~}|}||~|~~~}|~}{{}~}~}~}{}{|}~}~~~~~~~~ ~~~~|}~~~~}~~~~}~}{~}~{}|||}{|}||}~{}z} +z|}|}||~ ,+ &* "':2BN+ 5F=$  +6<- 8(p4L`XT^2 bYrvKjP8T.8DO +0v*&&$(hN\ZLd4@.XuWDFNBpRKDL8P^H@XhJlsj0*fWtWF x`PP`H tppP~~}}~~}~~~}~~}}}~}*||~~{}~~~}||~}}~}~~~|zz}}}||{z|~}|}|}}||{}|}|||z|}~|{|~zz}}|}{}|}}|{|{| }}{}{|z~~}~}|}|}~}|}|}}|}~~}~~~~~~}|}~~}}}|~}}~~}~~}}~~~}}~~~}}~|}~}}}~~}|}}~}~~}}~~|~~|}~}}~}}|~}~||}}|}~|}~~ }~~~~~}~~|}z}}}~ }~}~~}~~~,}{~~~}~~~|||}~~||~}}~}}~}~~~}}{|}~~){~}~}{|{}|}|||~}z}{}|{~}|}{~~|~~|~}|}}||~~~~{~{~}~{~~~~!~~~~~}|~~~~~}~~"}{{{|}|}}}||}}~~|z}{z||~~||~}~~{{~%,(3    >##6!$ NB ,E* +&0*\,D`DDx@P(hXX0h||Ȁ؜Р`p𨰠|аࠀМ𐄀Ѐ쐐АЀ𸀈Ȁ計ȰР줈䜨࠘ܐ࠘ؼȘȬЀ𢎈КظꚂ؄ȔЦиРبЄࠤŠ蘨Ĉꦇ@`@vڲ~ߢĘ̆|רچа x h`ȜP0Ĝܘp̠@Ƞxpर؀L?V]VJ-@87.&34(76#)+"*"$*0gP9?Ad?JGF(X=:> +4d2$(X*,>Z+S3LC(*hO,"J4J6j::HQBP@P0P xXHh`zиА5xЀЀРР؀ЬИЄȰȰܐаȬИЈИ؈Є؈𔈞ȀЈİĨԌ𠘌Ĕܰx؈Ԣഐ𠐴ȸ萘}P@` ЄȀȘ̰􆺨ʤРt܈সtxPPذزа̴GФ0xĐz0h{|@|x``p@`芀֋ؘ訐QIgHKJ=1! *4,4$D-D`R 0(>&("&.>.V[K71Y;E@D%2:*HJ:(&<:F-:!$648jR6aB$<,^::PBWU::D4W1H}0,b:8`lzQ4.<.$TWa@6r 9ASpua\LGrrUd$Dx@}|}|}}|{|~}|}|~~}~}}}|}|}~}}|||}}}|}}||}}~~~}}}|}~|{}}}|}}||}|}}}{|~}|}}|~||}}|{||{{| +{|~}}}{||~}{~~|{|}}~}{}}|}||}}~}}}~}~||~ +}~}|}||}}|}~|~|~~}2~}~~~}~}}~~}~~{~}~~}}}~~}}~~~}|~}|~~}~~}}~~}~~}~ }|}~}|}}}~"}}~~~}~~~}}~~~}}~~~~~}~~~}}{}}|}}|}~~~~~ ~~~}|~~}~}}|}~~}{}~}~~~}~}|} +~~~~}~||z||{{|}|}|{}~}|z}~|~}~}|}|}~~~~}~|~}|}~{}}~~~<~~}~~~}}}~}|{{}{~}|~|~~~~|~~|}~~~|}~~~|}!~~}{{}||~z{}{~~~|}~}~|}}%++   "& +:60",8.4&B' 4 (")3*8.( + "(zh0@`xHpHP耀АܠȠȂȠ𠔀ШЀиЈАЈЈȬ؀аȀ؈؀Аظ褰ȬȠؠ̸РȎжؘؘ貔ԌؼظФԈКȒ踄̐ذ蠮АȨȨȀЄİరب蘄ƚvޏ`Ԍ|ЌȆz}~zˈ̘ذpܴ|HĐȨPP px@XxȂpxԲȨؼO(&,<@D8H*$TN2>,,6*, <&//>//`4#J= 66%'&#.8)BJ4X&4208&62 &04Dp@@`h@`@@x|`|lp`Ph``ؐ؈rx@Фpxx`p؀hphhpxx@@ذРؠphX|phPpppp@lXȘh`|d Ȩtx``ppHhhxlPphdljxpPhH@PHtxP xP`XXhXHphv`ptl|hh\(bLh|`x~\Xrlr|0pTt`X`xdnzlllZpptX ȸlxtlИ|`ŴȈ|جؐЀx|ؠЈȞ䒤؎ʤЊԃ䘢؄|`0̨xЊ̮аȌ܈̈В⸰t д޶ηʴ¼ľꌒـڼƜȀЀhxԘt\`XЀ`h`0P@`MX`|XHL`ppptP`x pXP@x LP` @ p@PPXxx`@RHT@0@4(hX<$ d@XH@xPh8PHP8@p8 X008X@(< \$,DLL0J dH<DPxd wB`4h40(0T\.T@R2<* $*8H( \TC|F,D^|p| 4"FN(6Vb `VJJGH.@Z@@BV0 j4$ +~~|}}}|}|}}{~}~{~}~~{}}}|}}}~||~||}}|{~~}/||~}~~|{}}|}~z|~|}}||}|}}|}|}~|}|}}~|}~}}{}{z{|{}}z|}.z}|||}|~}}}|}{{|~}~}}|}|||}}~~}~}}~~~}}~~~}|}~}~}|}}|}}~~}}}|}}|}||} ~{}}}~}{~} ~}~~~~~}~}}~}|~}|~}|~~}}~}}}|}~}~~}}~~}~}~~}~~~~|}}~|}|}|y}}{~~~~~~}}~~~}~~}~}~}}~{~}}~}~}{~~}}}~}{}}~|~~~}~}|||~}{}|}~}||~~~~|{|}}~}}~~~~~{~|~~~~~?~~~}~~||}|~}}|~~|}~~}~~~~||{|}|}|~|}~~~~~|~}|}~~~}~~}'/241  +( & +* tVNR,C(" #N*P_4526+ +" + 6 &+n`x `PphxЀЀ𰸰ȊР𠀐؀ظĬР점؀ؠธА萌萈H찬иȐƘȔЌАȔ􄀠ԼȨ؜̘|~ؒʬھĂڊҠࠈذx갔 pĊ֙آа訥xzܘhphȋԈPxОƄРpzؙtpxzh̢̺ІEVSH4.&$D$..@D*"$<*|XNN"@"#&%%&Hq0,:&>*B>4,Pth `Hxh`x``@tppPxx@h̐hxP@pp|Pxpxh`pp|@`ظx ``dltxh@ؼd`x\hؠp0`Ȁpxxp` @`XpP`H8x|`Pph|`p` X@pTd@`h8|XtpzTthPth\``t`P(XhPx0plLHx~H tdp@@lhXxt4dn`x|hPH PX|xtxംԑȪĔȜ̆܋Ҁ@@@``蘨@@xȸ~䰜ƌؚ謨ȠȌД؞ҞƄ@0МֈР࠰ȐθଇԼ`|h|x{|pЀǸƲՀƾмԢ膐ȠHzdlPpt@XPXH\(Lb@HPxhlPPdXppP@ h`d0 LxP8xpHp``h X p@(NPF*Grt88l8 0tx +P8THLdMHi|Rd`bjFp,,}<.A::hd`@DN*B@JTD +H>4)/#"%?*>N&"P@0FRXP.>F2*,  +(&2*~D 0hXxPȐР`pp pب|@x`|xx0|hh؀Pth`p@x|`xhxPpx`аpPph`|`ph|xx@XHddd@hx`PЀxP`0hh XxtjPh(HPT(x`hPP|`8 hT`PfPPxH`p|t HdXhx`jPȰ```H0dhS~̰plhx`|Tth`pppPpp ȐȠḧ܈̠ࠬ͢u `Č躝Ȥ耟؜ކ҂䪊̛Ԓ`xАР.䴠ֺ̍Ԕఠppށڰ̔ł|фԾЪƌހԦԌľȸذИЀH XЀР@pl` XXHh~xh`@@tLLPhx`XxXx0 p`0`x0XH@`@00 HH@`@ @x8 0 (pXd H HpT`@H<\rlH4$$ (0H( 0PPl`ppx@jx0@8XP@ h8lD0B(e4PH48@8 HXN84<2<$+T(d8Pl(@ PPX@P TDX~84yN@. z4,XHPp@8 (P(HhT(4 P\"VG^>f JxXArsOd`>.,A"VNA>: s,@L<Hh< ^\0m^4lPx Xho^TL8Hl ,$0p*(Pz8pd~Z +R.ZKp~~~~~~~~~{}~|}{}||}{|}}}||z}|}~||z} |}|y|}||}}|}|}~~ }}|}|}~~~} {}|z}}z}||}~~}|}}|}~~}}|{|z||z||}}}|}||{{~~}{|} {}}~}}}|}|}|}}||{||} |~{|}y|{|}|}}|{|}{}~~|~}~~|~~~~}~~}}}|~~}~}~}~}|~}~~}~~}}|}}|~~}|~~}~~~~~}|~~}~}|}~}{}}||}~}~|~~~}~}~~}{~|~~~}}|}~ }~}|~~{~~|~} +~}}~~~~~~}~|}|||{z}}z~~zz}|}l||}{}~~~~}}~~~}}~~~~~}{}~~}}}~~~}~|~}|}|~~~|{z}~{z|}}|~~~}|~~!~{zz{}{}}{}|~||}|~}~~3;MOHF&V["$@$oR|6ldx02@0tFV4X0 +~~~~"~~}}}z~~{}|}||}}}~|}~~|}z{{}~~|{{}/|||z|}}|}}}|}~|}|~}}}{}~~~}}~}~~|}~~}|}||}}}~|}~} +{|~}}|}}|z|}{|||}{}}|||}}~~~}|~|}~|}||~}}~{}}~~}}||}}}{}}| }||}}|||}}}{|}{}}|}|}|}}}|}}{|}~}~}~~~}}|~}|}~}}}~})|}||}|}~~}~}~}~~~~~~|}~~~}~}}||~~}}~~}~}}}}~~|}~}}|}|||}~}~~}~~~|}}~}~{}~|}~{|~}~ +}}~~||}}{~}|}|{~~~~~~~~}~}~|~~}~}|~~}}|}|zzz}}}~}}{}~{}{}~}~~~~}~~~4|~}}|{~~}~}|{~}{}}|}|~|~~}{||{|{{}~|{~|~} ~||{||{}{|}~~||}|}~}|}||~5WD.;4 +$"($ 6*D +) @,i BNP,8:8@@80`h@@࠰xpؖ؈􌨠𰀈аИȔ؈萈МАذЀؠ 谰Ȅ蠀ДЀ좈찰жؘЄؼ̈،ȜԀؠи԰ظpƋۚpؘ@𠀠@``xԲª~쒆ڒиtpK xP@Phtx䠆XpP̼֤ hĠ`hp pŽРƀB}:$.N8 D@*2-<62@..:6:6* "("F<^>6B:.><2**34*0\0D4("*BFj804D8H".66 0&b&2: B^82*&.!2,,$>B44$(($"(2@P0&(P`HD08hXH`0pp pxhะЀxȸh|xx@x`hPhpxpp``hX|hp`pxX`xx`0hPXp@`h`pXP`XPdPPHxpphX @xxpPh8dlx0x|hP@Pv\lh\|ȤrxP``pddp`Phd\pĐ@|p8xȺhvphdlphpl`pP̸Р@P`xpXȐxxdʌX؜~ķxtРȠ0xȰȘȀP0`PȀȐВ訸°ؚĺ檐΄䐐ฆ਒ބĐ𤠀Šƀ􊖊򀙘俛@ ̸Ѷ˜ִЃ쌚ȬЬذ؀蠠`PPА`ࠠx`V``0HH(0( \ lPltd18@|L8(HT<@0<$xx8w6F@"m2(@5DhhH@@0P` H @X`,h8(g<t,B$0;V6(.Fy_(D$X*zRtrx$D $~l$32)&>OP$00*OZo(0hR@|t~~~~~}~}~}A|}}|}|}}{~~z}}{~}}{~~z~~|y~|}y}}||}|{||{}|{|z~}|}}||~~}z}}|}}|}~}|}}}||}{|{}}|z|}{}}}zz|}|||}~} +|{{|y||{}|}{}~~}}||{|||}}|~~}~{|~~}~}|{|}| +}||}|}{}}{}~~}|}}|&{|||z{|}}|}~}}~|~~}}~~}~}~~{}~~|}}~}|}|}}~}~~}~~~~}}|}~}~{}}~,|~}||~}~~}}~}}{~~}}||}~~~}}~}~~~~}~~}}~~}~|}}|~ ~}|}}}|}~||~ }}}~~~}~~}~}~|~|~~}~}}|}}}}}|}|}%|{|}|}||}}~}|~}}{|}}}~}~~~~}}|~ ~~~|~|~{}~~~~"~~|~}{~}|~{{~|~~||~||~||{}{}{}}{|}{}|}{~}2X50,  ,4 *.>40 + 4X&*2(2@X ,,6 H08(@Xx``Р̠䌄`Ȉppp``Ѐ@|脐аааؘ𠨈ࠈȰȘЀఄЌИȐĐܐ舰АذȠ舀ĖИب̴ДȘЈĀиܬȴܐА舂Ю‡ԘȊظ@pĠРИ|ఈА`~p@pwܚԓzx{~p txоԱ~~ t܀А `x@𝶸S6&:&`>RA8FJ2<,LR":0.2: LJR.HF* .*>--&HT ("2@6B`p0 PV@ h@XH`P00p`؀ p@pĠ䌄xȈА``ЀؠиdxHhxP``а؀pp`@@Phh`hX0`@phX|Ќ```hHx@h`0@p`px@ `h@|P@XppPx`h `8h0j|vt|\t8t`rppP@@d`0dĨhx`p`hpȨ@0p\d@@0` Ҝ`ppȘvhhPԤxXxxhhܸPpPXpx|h`prĈvxXpć̔䐬xȈ`t؀Ġ||PخА숐䁲ܙЀؠ𠠠ÞДР` 0@@ʐځ̮خشĸ̄АਠPȠ|Xh@@؀иx pH\xTP0HH *[R\@h.bSJ`EG;u@FPeS($`~L<_,<4F,VR0fTN[}4wxH``0~~~~~}|}%{||}~{{z|}||}|~~~~}}}{~~{}~{|{|}}|}}|}|}}}~}{~~|{|}|~}~~~}}}~~}|}||}|}{| z}}}||}}}{|}|*{|}{{||}}|}|{||}}|}|{}}}|{~}|}||}}{|}}~}~{}(~~{}}}|{}}}|||}}||}~y{y}|}{}||}}|~~|~||}| {||}z{}}z}}|}T|~|~|~~~}{}~|}}|{|}|~~{}}~~~~~}~~}~~}~}~~~}}|}}}|}||}|~~}~|}}~}}}|}~~}}}|~|}+~}~~}~~~}}~}~}~~}~}}~~}}}~}~~~~|}~~}~}}}|~~}~~~~}}~~|~}|}}z{~~}}||~zz}}z}||~z|z}~{z~~{~~}~~}}|}|~{}~}~{|}~}{}|~~~~}~ ~~{~~~}{}{~|}}~}{|{{{}|}!~}~~{|{}{}z}~{|}}}}||{{{&:2&2"" +" ( H,8<\8(<,8(088@H\80 Hpxhp`Рдp𐰨xظȔؠؠxxؠЄаȄАаȴؠЈĨȈ蘠РаȜ蠨ЀԴА伨Ф褸̎Ԓޜز踮`ʞǘЈ켈𐨀ȨИ𠐈 @И̐@Ȱ`؈|zx0ȔؚtƢʔxvvpd|x|lꔥ0}PxИК؛<9"**. H*H6M44"P,dN@`ZzNHd@~~~}~}|}}|}||{}z}||zy|}|}{||~|z|}~}}|||}}}|{z{|}|{| }}}~}|}}||}}}~}|}}~~}{} ||}|}}}||}}}|}||{y|{}}|||}}}{|~}{}#|~}||~}}||}}}~~|}{}~}~}}||{|}|}~||{|"}||}~~}}~~}}}{}|}|}||}||{}|}}}|}}}~ }}}~}~~}~~}{}|{~}~}|}}~}~|~~~}~~}~}}|}}~}|}|}}~~~}}}|}}}|~~|~~~||~|~~~}}~}~|~~|~~}~~~}~{}~}~~}~}}~~~~}~}~|}}$}~|}}~~{~~}}}|}z||z}|}}z~~}||}}}~}~}|}~{|}}}~}|~{|}{}}}|}|~z|}{~}~~~}}|}~~~~~|~}~ }|}%}~}|~{~~|~}~|||{}||z}~||}|}|||Y2.". & + & ( -8<:P @,PHXph`x蠀Ph PX`Р耼ؠИиxܨи舠쐐蠠АЀ`𠸴谠аਤРؠаȰ興ĠȰసаؠȨа،ਘȘ̈Ȁļฤؒ؈Јv„聢╌ЀаȼؐЀ䀀`Ȑ|𰈀x`xЈ@Ȩ܃ҬPė}ذp0@HȬІHdzӢ|־ ΨvĶx#@|ЄY<$6L."2 & >&.A@F28<.,44H@0P,:B822.*(4!'6?H,Hh4 0P0H0@PH@Hh@px8`HHP`pؠhP0pxpppР؈``@`@0pxPPЈ𰀀pఐаp@@p`p 0pphȔ0th`p@xPh tpTvPpx`h`@`xȀȬxpPPp`@`Pp@0l@Ƞ|pPXrp hXL(hx`Plp|||vd0HPhpHtPthPԈd\X@Ԡ``t|`t@x|0ʼPH8dȠ`x`x}؃ xАȈ薌t@P`ఠp@pИ𰈀ބԮ°НĝȨȬѼ۪€óƈ#тxPXpp pܼԪ躮ܘظ༐@pЈа@h 0` ``P0pppPp``000 xpx t`@p@P@8p@P@ @@ @P0` Pp0`X`P`PH080\H tX P0`T8P`00\D00 *@(8HPp80@ (0P@08P @ 0LLH\@PPLLXt ( X\ pX h0( LP0DBP ,X0PzDPXN&dH`ttDL| DT x`JN,^< ~f&30WZZ$X0HPp8Pp/0`(LHp0P( @Pvh0H@L0fX|VVjpdT]BgWDn _4@/$CFaQ@tE4]OspNzjz h.L$VJ X@H @P~~}}}~}6{}~}}~||}}|}}}|}}{||y}~{{{}{||}|||z}|~|}~}{|}|||}}}|}|{|){|~~~}}{||~}|~|}|}||}}}||~~||}}}||zy}}||}|%}{y|||}}|yz||z|}}|{}|{}~|}~}}{}}~|~}}|%}}}~|}|~}~}~~|}}{~}{|}}|z|}|}}}~}}~}||{}}||{|}~}~}}~}}}{~~~|~~{}}|}~|{~|}}|}~}{~~}~}"~}~~||~|}~}~~|~}}|~~|}}}}~}}~}}~}||}~~~}~}~}~}}|{~{~}|~~{}~}}~~}}}~}~}}||~E|~~}~~~{~}~}~~|}{{}{}~}}}~}}~||{}||}y|}~}~~}{||{z}}}~~}|}||}}||}}|}~}|}}|z~~}-~}||~~{|~}||}}~~~}~~~||~|{}~~{~~}}~}||{}}}~{y{~|{|||'.+3N4L<2$0 +) $,( ".6#'&$<6 @(0H@Xph`pЇް @p耀@ԸศИЀࠀİԀȀАࠀĨȐؐȔЂАبȠ؀ЀȔఐ𠄠ܠȈ輌𐰘蠀АИЪ،젘ؘАࠀ莘Ќ젨ȸܠЈĴ䈰訪̒ȸĘ𠠀蠠@฀ШАИʜp򢌴ⓩ֞Ę䄨x䊜ztĦzx`x̂ИҌxH0ǀz~ȀШ𰠸6:49T4J4*$ P0="#$28'(:B>'%>(6%BJT,/489.L&)(RND,0P@8@8H``H@@P`Ԥ `p0`耀@ศД`pppа|@@x`@x``hаpИP `@pPpxX̰dXX`lptxtHhpnp`xpPP``px@0ph`` `XP0p`hhHxPhpxxبp8X~(X`xp (ptPxܬx8p|H@X rԈhpt``pttrlX xxxppt x|H|xЀl0dxHpt`8茔̨쨪ʐЈ@|Ș𰰀จЀȠА𸬤ظxʜ䌌°虫֜ȠȤΈھ԰܊Ԝ̈́Xఀ޴ª螋Āиȃ̐tȰ@PР@`hH@hxPpp@t(`\ D<P(^, 4Tpt@>d,P< ,0J4@<<~8LTU06.<b|lT0 @@0hxXPpH(8@T1x,GFZZHM8N Y@@0x  ~~}~~~}8|~~~{{y{|||}|||{|{y~~{{|zz|{~}|yz}|{|}}|~~~||}|||}}}{|{{z|{|{}y}}{}z{{~}~{~}{{|}|~~||}{}|||{}}}|}|{}}z{{{}||}}||}~|z|~}~|}~}&~}~||}|}}}~}~~~{~~~}~}}~~}}}||}|{}}|}~|}@|{~}{|}}|||}|}{||{|{}}~}||}}|}~}}}~~|{~~~|{~}~}}}}~~}}}}~}}}~}|}~}~}|~|}}}{|~$}}||}}~~}|~|}~~}~|~}~}}~~~~~}{~}|~{~~}~}~}~~~}}~~|~}}~~~{}}}~{~~~~}~}~~}~}|}}y~|}~~}}}z~} ~~~}||z|~~~} ~|{~||~{z}|}~~}|}~2~~~}}~}~~|}~~~~~}~}}~~~{~~~}}}~}|~~{}{}{|{|}y}}|||}&$0DB*B$D&3 "."$*0"% ,, + +4H($H80@88XppXPXЀҼ` 0`@@蘀```𼀀иࠀа蠀ЀȨЀxЀȐ𐠈иبЌĐܨЬМЀఐࠨ섐ДȬذ؂Ш𸐬ؐȨЀİؠ؀Шд𰀐Ĝ|ܮ𨀀躜舐@蘐p`xpxxИਠȀȂy̏мVΌĎо~pаPtؠҍցԾP Ѐ$*:: <$V.B@/:F:*-:"(F:628DB4&&"**6,,$p| 8,`@@P88PhX@8@hڏ̰ X @蘀``Ȁ𼀀p`Ȱ`pp؀p0~ppp|`p xX𐠈p`pxPpP@l`dpxP`hdX Hxt0̨X|||r`PxxPpP|pXxА`h`0h`@x`x p@t|`dp|t`p``z0`p|@P LXT``pxȰx8Xj8ph`zl `~v`d|xxpl\ظ`ȌzpzpP |Ԅpxxxlp̂Ȟ𤀀ض@`ؐЈЀȸЌ؀ؐ ܤȠҒڗȜܔИҜąŷ蕾ԕȫؑ؃`Ѐxpx򦂂惌ԁΞШ@h҈@舀dpPx@xpȸ` ` @P xt x(Hh<(ZP@P@@00T@@x@(P@00``P@P80P80ppPXl @ t@0Hx@08<(<hTx@000`dR(XP0XKP HHxHp8P@0P00@P@@PPx8`0`D ($,<8848P0D^(DXX(L ,`(DL|T<b~J8`4xTH,NF,<^D@FH ,X(0l\@Z]<$jd`bTHH$LH8X$x,$&&X nnb Jp qn@DT(X0h0 pT)HH phLpP(,h\dy{~}{|~|{~~}|}}}~||}~~{~~~}|~~|}{~~~~~~|} ~}{{||}||~}|}~}~~}~~~}{{|{||{{}}|||}  0<  $,  . + L4t0P0  @@pppȐ܄`HX@`ЀȈx`ࠈАа𰐠ȈĈరиਐȰШਈ𨀘؈Єؘ𼈐ȰиЈਘ@ȀరРЈȘبܸؼФଈЀиܘЀ줘ؼРЌĀఄ਄࠘茐x0ڞ؜̤``Ԑ،@ЀȀЀpԀ耀аи`PԖ~ܤ`їЊ➖ܺ漤܈t`pܨȔ@0n΂}x p@( ,<$$0246$( "B 0862&62nT\Hh@00 @@p`p``pppP ؁pX@P `ЀАx` pxxpphxЀx@ؠ`pPlhx@p@pਐ`@Ȝh@`hhp0@xppPP`p`X̰XxĀTxl|xxؐpdpx`PlXpp`xp XxЀЀ`|`P0ȀPX`Pppp\t PPhx\0@ tphHT8x``ȐHhp`hxtpP(X@@|xtxp`XРpxhdȔh`xp`tАࢎܜȤԌȄxаpxЈ耀Ȕࠜ`и읿םڔ裛«ʖ씈Рк줞܈⒰҂฀xx̴ȀԎ҉ʦʰ¾Ԉиx@lx`@ИЀ`PpXP0THАh@@pP`p``@Pp@(h@l0<|HH@Xphpx``P`@8x P@`@p@8 h\L(P`H0 8X84x4 4\8D 8`|LT(@ 00pX0@D4\00HP8@`h  `<0l(@0LP@PT\t44l@D`<$@ @ xNppDNAVj,,,rjd SH5RH_UhNpP0~ }}~}}~~~~ +~~~}}}|||}|8}}|{z~|y{|||}{}}{|||z~{||y{}}|}~||}|{~~z{}}{{{|}||}}|}|||{|}}{}z{}~|{~|~}~|}}}{|}}|}}||}}|}|z}~}{{||z{}}}{|}}|||}~}~}}~}~}}~~}}~~~}~~}~~}~~}~~~}~}~ }}~|}~}~|}M{{}}{y|y}z|||~}{|z}}z|||}|}|~~}|~|}~~}|}~}~}}|}|}}~|}~|~}|~~~~~|}|~}}~~}~}~+}~|~}|~~~}}}|}~~}|~~}}~}}~~~~|}~}~}}~}1|}|~~~~}~~~}~}}|}~}{}~~}|}~}}~}|}}~}{~{|~||~~}{{}{~|}~|{z|}}z}}~}|~||}~|z|*}|}}|}}~z}}|y{}~}}~||}|}~~{~}|}}}{{~~|~|{~~~||{|||}}~~~}}~}|{|}zy||}}8@Xp `XDHdT=2.($(H 0@PXp@@hhPPԀ𐠘x𠠀ЀȨ࠰ठ茀РРЀ؀؈Є؄ШȐЀ̐ؔĊ茠న``􌰌а؀иࠎ̐Ԅذؐఠ褾̂Ϊ⚘蘨ȼؘܴЄύȔР@@@@ЀЀxȼ@аࠀxp@𠐰®ҋ@}ఌx0xx @̐~֐̄`PXxƚrx蟯xС@,(p@`xPPHPtdBB@<*,&0*8<"(0.2 &l6@0@PPPPXPXpX@@pԀ𐰜`x@``xаhh1ࠠĨ`p vxhph@ЀР𸈀pЀppx`xp`PddthXh\@PЀh`hHptxdhtPXxpp@И`@p਀xlltpxPpPx@PHx8zpPp|Px`tj @X~xpdx@p nԎhh@0HXԔܠTppXhx|pxppx@ҐȘРp`@@@Ѐ𐌰РА`ฐ|蜀ȶܑʨ؉ĂKঔЅ܊ȭܮ؂֩耠`ӣĘƺȺȘРp0HP`Ѐ਀ԀP|X`@(lP@phPP`>Рp@xX`|0hX`L`0P( p\00PhP8PHhhAPH0 H@8@@8hH`@ $8D4P`D|HNRh8 <XDXXHm8Xlp`p PPp@ Lh`4 L(8\<P\8Phd hjX LP0*jvPldp4L,P|TXphX48dZ f&p,H@0hhH(\PTT0h 8(4@~"D@@cCNC", $`8x&80LX( 8X|pPPh`Pp `xX +@PL\HL%(nPoUhQT2SURDTPx4 @SrV&0L`>*2?f*4fTVxX>X`PW?mp0<`8~}|}~~}~~~}|-}|}zz}|z|}}y|{||}|||{{}}}~y}z|}|~}y|~~|{y}~|}}{{}|{|}|||}}||}|yy}}z~}{|~}}{~|~} +|}}||}|}~y|}}{{}| }|}}}~}{|{}}}z}|{|||}}~}}~})~}~}~}|~~}~~}~~}~~~}~~}~}}|~~}~}~~}}|~~}|}}~{}}{||}}{{{||{}z{}{{}z{}|}{~|}}~~}~}}}~|}||} ||~|}~~}{}|}~}|~}~ ~||}}~||~~|~}~}}}{~~~}}}}~~~}~~}~|~~~}~|}~}+~|}}}{}~~~~{~}~}}~|}~|~~{zz}y~z}}z}}z~~}|{|}~|zz||}|'{yy~||}|~~}{}||}z}}~~~z}~||}}~~|H}}~|}|}~}{}|~}~~~{~}}}~~~~~~~~~~ +~}z{|{|}pxhHxP0X8 8JJ   + N8xp``ppphhpаؠ@H`pࠀȔĠఠఀࠐȸȰИਰЀ蠠ЀȐШ̘ԐؐШਜȌ萐ȰиР@Ф𐌴Јศฒ܂𖘀؀԰ЀЀ위Ѐ؈Јఠ`|`xpxȼԘЀp`Аv|茰~0`¢|܁~|μРh@p0栴ޞ`̘`x|伌|hذP`X`P@pxP0X08VV($ *(( $&@@L(&402.l,0`p`P`pph```0`X@ Ѐp0x@p`ؘȌ`hఠ`hhhphXpppȘ`hȀPt`hpਰ@`pphИt`Јpp`@p`XPXh̤@hX@zp8\Pr0H`x |ph\`|x0Pd`HXxؐphЀxP Ph`ؐx`hH`PPlXHpX`PxtjhHp8\Plhjx@ܪtlx`؀0TĐẌpXhlp z@pఈxب윈␬䀤؀аФİ̈x```脠؜ȨԠ؆̴Ȑ섰ꤞ舋֒Šȼа𐸠΢аАx`P0PX0|xp@x\@@ఠ ``PPXH8P@=pPP@0`Pxh``@@ (X`@pThH P@X`P @ `8@hphD`` 70P00`0((\jLt`RH\ 84`P H @@`P ,V6$lPhHp`08X|p@8 hH`(l@ 8PDrP?8\ P@0L8 e^<68\ . PV Hd@(6`N0dvddHt,dT\4\(h $0H^(| fphZvT H X8dhd 8pn^hp0PHhDX@h 8`<$ p$HH`ZpDH 4D`TdJ\,Tp,@r(8L4DH\0hlj(0C8\DHLPX$PH0L, 4,<4@H,8<D <6:*h`F{pNr h((PH@XHQ(hPX pPXH8x`hp` X@Hhx@0`0HPLXDH|LH,JeLZR.",,3V:p6Z2$L@dH@h VhO*r.i flP H(TD@`VF~}|}}|}|}~}|}~~~~~~|}||}}}||{z{|}}|4{{|{{|{}zz~}y|||}}||z}{}}~y~{|{}{}||}}}|}}{}|y}}z|}|}|}}|!y}}}{}||~|~}|}|}}}|}}~~}{{||~~~}}|<}}~}|~~|||}||{|}}|||}{~}}~}{}~}}}~~~}~~~}~~}~|}}~~}~}~}|}~~~}~|}b}}}{}~||}{||{{|~}~{}||}yz}y{}|~{}}|}}~~}{~~}}}~}|}~|}|}~|~}|{}}~}}~~}}~~~}~~}~|~}~}~}~}}~}~~|~~~~}~}}~}}~{~}~~}}}~}}~}~~}~}|}}}~~|~}}~~}~~~!~|||}~~~}|}|~~}||~|~}|}{||z|}z{}8|||{}}{}{z|}|}}y|||}|z}{|}}|{}|~}~~~}||~~z~~z|}~}{~~}~ +}{~}~~{}~}~*~~~|}~~~~||~~~~}~~||{|~||~}||}{~{}|{xD00@P8P(P``8(^,D<84h@hx0t(H00\@8\l4 (YRSu9JM4FAK@dbpdh@p v,D*..@@M(,BGN 2,L <(j&V*DF\4Q}~~y}~|}|||}~~~}~}'|{|}}||}}}|}|z}}|y|~~{y|||z|}}}|}|||{{} +~}|{}~~{{}|{y|}}}|||}||}|{|||z{},|y}|||}}{~~}|{|}|z{}||{}||}}|||}|||zz}~}}|}}|B}}|||}{}|}}~~}||}|}}|}~}{}}}~~}~|~~}~~~~}}~~}}~}|~||~{~~}~}}~~~}|~}{{}:{}}|~~{||~}~~~||}}}|}|~}~}~}}}|~}}}~~||}~}~|}~}|}}|}}~~}|~}~~}}}~~~~}~}~}{|}}~}~~~}}~}~||~~|}~}}~|}|}~|~~|}|}~$}~~~}}~}}}|~~~}}~|~~~|~~|}~A|||~~~}}}~}{}|z}}{|}z}|}~}~~}||}}||{y}|||{{{z{~~~yy}}|{||{|{||}|{}|}'{~~~~~|}z~z}~}{}}/}~~||}~~}~~~~~~}~~}~}|~}~~~~~~~~rh`ИЀ@8 :06 + +`40`p0``xȀ@Pp𐈄Ȅ؜АذЈАࠐЄиРؤРp谠ఈ𰀠ธ쨬؈ЈИȐаРАԐذȰȤĨԈİЦ茄ДȘΠظԌĠȄ蘼谀ג{ج0`@Ўt@@@ఠ𰠀Р@@ lЬĤ`dp@pЀwxt`ҕzԄtpp `0 |Ċ +pЀԐ𘸠ЀP(hXL:H"2&:,$& ,zL ($P@ XPp|p0HlxШp`耄pp@ب؜`xЀЈ`x Ppl`PpppАpЀpppPhl|XPxp||Lp hpp(`PhԈpdx`x``@ȀXpp@t``x00`p`ȸ xtPT@Xxt(phdpl0|ph`hxh0xLfPPXht|x8wlh`P@ttplp@`dpxxpt|`l ph0`ddHxPXиݗ󀘰𚌐{Φxx̤xhp`аР𰠀e漲ήƢЈԄȂЀ䞾ڜܺҰܮЀȶ̐Шrx`hzp``΂ްȮذа𰈄|Ѐర`0 `x0pthpPH(@xtt0pPPP` PH `0@ph <8@@p((Cpp(x@` `pH 88 8H 8( \llXh8<$Xh$$@d L"8\TX <(H(hl8P|@88L\(( Pxxx\<(P tH|\p00 H D\@Hl(4H8,`@08hdTlD<"B2H WH D0 ( `$ (XTl((^LHK$P,HnLREB/d&*kEKO;_^t26Hd `ph(@8 ^`8(XX P@@p@0`@ d| 0p`P8 +H&&J*bX2H  L(^V@ +<Dl/^]8$h&<6t@$U>R"N"JNdR<$~}}~}}{|}}||}~~~}}~}}|}||}^~~}||}~{{~|~|||}|}zz}~y||{~{y~}y}|~y}|y|}}z}|}|{}~z}~{{||}~~y|}||}|z}|z}|z|}{|}{|}|}}{z{|}||~}{}}|}~}||}}{|||{|||}}||}}||}}{||}|||}{|}~~~~~}}{}{{|~}}~~~}|}}~}~}~|}}}~~}}~}}}|}~}~}||}|}}|~~|~}|~}~~~}~}~~}~~}}~}~}}~H|}~}||~~|}}{|}}~~|}|~~~}}~~~~~}}~~}}~}|||{}}~|~|~~}}|}}~}|}~|}~}~}~!|~~}~|~}}~}~}}~~}}|}}}~~~}~~} ~~~~ ~~}~~}|~~~}{}z}}}~~~{{zz}|}|z||z| {{z{~~}{|z{|{||{yz|||{|{||}zz|}}{~J|}~~~|}z}~~{|}}~~~z}z|}~~~}}||}~~~~~~~}|{~~~{|{~}}~@|xp0P@ (@ @)""6L\P@X`PXhp@@pXxؤ؀𐀀Ѐبࠀ@ܐ贀Ƞ Ѐ𸠨莴耔ĨааȀЀЈ䄨ؘȰ䴴耠РĨ芦ʈ଀РļȰȠȀܸ膂 򮕂̮ܰ䰐~|~؜̬ЀȸЀ@Ԡö`~ڲ􀐀ȸ@pd|v~tjݯЊyh@|ph@Ќ|՜АxH<0P N200,:>6,*,:@(ttP8PX``xpp@@``ؠ|x@؀|x `p@`|بxаܐh `txЀ``Ђxh@hp̨|hPdX8XhP`hxp@hpthPPh`@h`P`tpxP@|Xx~xP0H `pP`xX`80Иtdp0x08|Ċn`@XPlP0@xh x lh|P@vش`tЄ|}tzlبఀԄؘЀĤ`pЀȸЈर̮żؠ𜄌鼷ĔȺĘƐ@ĄРڔӛఈ𰠄ʪ҅ठа؀Иpptp0@ @lxrpXtH\h@hxx@ PP hX`H H0 @@`pXpxx0@phPllPTp@@XX0`0@0PhPH HX0@`PpbT`tH@hT(pL\ht$Hd8 Hp`HP(8X (<@ DP8DT,hl 8X\Z`h L8`0L,h@PH,$@`\hNPT$TF$@f$<]< X( m$tl8H( \@(L,4X(T, <AQF97E/N<>L3eZ4^ZXF$(v (LhH8(X\NNlxDpH@TP` 00Pp@P @`p @@0hX8`DX.N0: pPXHt,&CIil8qj4&jbW +Jm>~jDDPBL8B,fTK4CQrxKqbK~~}z~~}~}||}}~||}~~}}}~~~}}~} +|}{z{{|}}|}Y{z~~|yy~~~|{}z|{{||y~}y|{~y{~|}~~{}|}}|}~z~{z~}~~y{|{}||{z}}y}~||{}|zz}}|||{|}|{}~|||}|}~ }{{{||}}|||y|}}}||}|}~~~}}}|~}|~}}~~{~~}~||~}D~}}}||}{}}{~~}}}~~}{~~|}}||}|||{}|}|{}}{~~~{~}|~~}~|}~}}|}~|}|~}|} +~~}}}}~~}~$}|||}|{~~}~}|}}~~~}}~~}~~}~||~}~|} {}~~{}|{}}||}~}|~{~}~}~~~}|}}|~~~}~~}~~~|~~}}|}~~~}~}~||z~z~~~|||~~}|y||{|z{|yy|}{y}|||{{||{|{|{{}}|}2z|{~}~~}~{|~}y|z}~}}}~z{~|~~~~~ +}}~|}~~|}}z|~~~|~|`Јp`Pxp`P084 1P +2F.&ȘX@pp` xxظస耀@਀xxАఐసЀİؐАЀРఘИиЀȠبԐ؀РЈ؀ࠀЀ𘰈䔐¬ظРȤشĠмЄȜĘʘ𸐔҄Ȅ𼜌ИܘघRĈĄ򌁄¢޴ȎȀ~𸸐h |𴆘@@̢xɦ܄Ј |pdԶzĭ֐|܌ʠr{ |`z +րpȀxpX@XP@@( 0;j442("&4L`:> Pp`Pxp@pЀаఈxШppP@l਀ЈА`xР@x @АpАpXH pаࠀРаxhhpx`ؤh@`xPP|@xl`pttP`̬hPx`h```P``t0` p PX``hhxԴ`\|XhT|vdpP|pHHxظ0@PnhЀXx0`z|0hrrT|~䘨xHtP nPPdX||xxqP`x@x ؠ~`x`Ĉ`X`Ąؠ```Ԡℜpp`pΨϭИऀһȆ援؈𓨰ĘpĐ Ԁ𨠨ȸФ趂삲А|Ƞp``xl@pppph x00dPpTPt XP(P00@``ph P@PhP(00( @PhXXPp00 P@0XP0008@ 0xt0= (`Xll80$00D|8@0@Pp@baH0r@0(ddDH@x x08, t(,?^TTX $d84^DV u^lrVbDp9@*2_bnclRLLV @d8l0 0P@`pP'0ph0h`@@Pljpx6?XB(x80`0 NX>2K\FH#*_"*N84^pi`O6F5-|||}|{{~}}~}|}~~}||}}|}}~~~^~~{~~}{||{}}|}|{|}}||y||~~}y}}z}}~}|{~}y~}y|}|z}|}}|y~}}}||}~~{~}~||~||~|{}|y|}{}}|y|}{|}|}}}z|{}||}~}}}{}}~}|}||} ||{z|}}||{}}|{|}~~}|}}}~~|}|}|}~{}}}~} ~~}~~||}|}}~}I||}||}{|}|||~~|}~|||}}{}~z}}|{}{|~}}~}}~~}~}}~~~{|}}{}}|}||}}}~}|~}~|~}~|}}}|}|~}}}|~}~~~}}~ +|~}|~}||}|}{}~|{~~}}}|}~|{}|||}}}~~}~ ||~~}}~}~~}}~~|~~~~~~}~4~}~}~}}}||}|}~~}}~}~~z}}}~}~|}}|{|{y|{|z{{{|{{{|||{}yyz|~|}}{||~~z{}~~~|z}z{z|}~{}}|{}|{|{{~{~}|~|{|{|}~~~|{~~~|~|~~&p``xpx茈ph &*xhxp¸ЀРȀЀࠔИаpxȀРаȨȰఀhxxనЈ萠Ѐ؈ؐИȈАؠȬȴЀȐذؚ࠰𠚤𸐌̆ČଠДȌԘ䢂𰰗Ěϸ̜Ȋvp`~|l``Ƞ `@ЀЀ|``|V쀀@@@PpȠ P@@`0ڐ|p`|@Ⱦҟ؏tқހxhPpp0x`xh؀x@pP`P(, 0>>, + Hppĸt@؀t@ࠐpx@Ppx`pаАxxРЀࠈ@Xppppp`Ѐpxh`pxPp`pP`XHH@8hh pPh`|``P}PhxX`pȘ\\Ph|Hl0xp0@p`xpX@dtp`Xxtl~`0pPdPX|ȠXhXLXdxt`\xphPx`|h@xp@xp`pPphtxx̐|ࢀ`~ɴʘะ`p``~@@ Px``pxȠȢȐаиసȼ̒Ȳ䚆ܡp𠈠dP@Ѐܖζ䰐PB|p`hXxhXxx thP@`p@ |0HPh@TPmfp p0 000(0XHp@(ph` Hp8 `H8@@P`@HH0x@@0``0X`h8@80@H88@08HP@0P(( (` xPP`p 0PPXX[@8H(00(8hd8h8X88x,\0v X08P8LTh@qYe6Z6F,`RL`@H  P @ PXpp`0`PP`@xhV$Zxi~h4 |@p@fDH4F7e@\Z,>dP\ZR: *k)Q ?( }}|}}|}||}~~}|}||}||~Q~~~|}{}~~z}|y}}{|||{|}}{}y}z~}}~y|y}~}}z}~}|~~{||z~|z}||{~~|}}{||}|}}}~{~}~~}|{||}~|}}{}|||}|{}{|||}~{}}|{~}}z}}|}{|}||zy}}{{{|}}}|} |||}|{|}}~|}|}}|}|}}}~}{}||}{}}}{~~|}}||} +|||}}|}~{}~{~}|}}}~~}~}}~}~}||}|{}}}|}|~~}$~~|~}}~}|~|~}~}}|{|}||~}}}|}~~}~}}~~~}~{}||~~} {}{~{~~}|}+{~~z}||z}~|}}~|~~|~}~|}~~~}~}}}~~~|~~~}|~~*~~~~}|}}~{||{{~}|||}~~~}}~}{|{~~|}|{||}}|>z|{z{{{||{{{||}|||y||{||}}|}}|{~~||~~~~{|z~~zz{}}|||~?}}}{||~~~{{|~|~{}||~}|~~|~~{{}|}AȐ`h`ª𠘰 `h08(40( 8x𬈰̄px耴Ȩаp@ؠАࠀШ蠠𠘠Є𚨀РРȠЀАаȔ蔼蘀̌蘐ȈȄؤȠܘ𔊎ԠȨĴ؀ଐؘȰธఈ؀жԠҰĘ܀Д̈쐰ԄԀ@͘dPphĐ踜tذ@̬hǃPдܠ`P̰;x؈@ |@tϫ8tx|֘؈؍x@@`Xx𠐰X`08(888 ,HPа``h``x``x@|@xxhx@Ра`ؠx`xP`p@Ȁ`pࠀ؈@hpppphР``hpth|`h00`PpXP`@@Xx`P@@`hpp~rpv8t\@`P `pаpxȠPHh|p@X0hp0xrvtp|~Px(pP8phf0輰PhȐhxp@Px lHlȨx`ΤpּΓpXz䒪ȱ𦠌סЌĎxԬp`ȘH`@`@p@ئȀ͉и؀ЀИɶز؀ҘԯؠЀpИظذ@@Pp@`htpPlHH00p @0 rh X@P pHp\@@0Hp@S0`00T|0X@0` @0pXP( 0P0`@8@ p@@@@ @@`ppxh`|8PPX8x H0L(PnP@( (`PX@8PPp'X P@0X@ d0PHLph\d84\V4HHx @`pH(L (|0lDDd8\B>F4T$L X@NN$bwM*pbA\d`0HfZ 4d H 8X0``X P`@p P`@l 0b(U{b]X|xEph`8(0TDL0%h=${bDV NLENwXTIoqL\VU<*.2.7%{}||}|||}|}}}z~}{}|{{}}|}}}~}}~}}~~}T|||}~{|}}|{}}|{}||{}}||{}~y~}}~}||{~}}}~}z~~z}}|}|}||}|}|}}{}{||}|}|~~}{}}{|}y| z|}|}~|~}z~},||}{y}|}~z}}{}~|}||{}}}|}{}}z}|}|{|{|}y|}|||}|}|{~}|}}}|)~}}~}|}}}}||}}{}~}}|{}~|}|{{}|}|}}}||{|}|}|}{||{y~|}}~~~}~~}}}~~} {||}}|}|~}~}~}}~}~}~}}|~~{}|}}}|~~~}~}~|~}~}~~~|~|~}|}||}~}J~}{}|}|~|}}~}}~{}}~}}}~}}}}~~}~~~|~~~~~}~||~}~}~~~~&~~~|~}||~}{}~~~|~~}}}~~~|~}(~}~||}}|{||{||{}{||{{|{z|||{~z}|}~}~~{+~~|z||}~~{{{z}}z}||~|}~}~|~~}{{|}~~}~}~~~~~p@@Иxxؓ@@X(pXh`@xl0PX0@Р@ؼФxȀ࠰P`ȐиࠠИȸࠒȸذ𠐘ࠀАذȐ̔Ѐ؈И椬࠰쀌ȨЀ̤ȠరȠМʰܬȐ٫zľ܀ʂʴ耘}œhn|аО֌ؖࠀ@ĸ0p@h}`pPhp|ڭ ¦l~ftԘP|`@̰ndtpp|p @X(pH``` @`@@x@pp@ఐpp|x@ؙp@ȸؾ Ĝ@@ptPТ言``ࠠ@ppؠp`pPp@ppࠠPpx@pX`xph@x~ppH`Xp xp0``h`ppvp`xhHhdl``lPpHx`h PttxX0lP`hx t`ʈh|`t|lx|` p@xp`pxА px|@xp ~`ƀऔܤd֪Ⱦވ֊Ȉ؜ʘPz` @``` @x@xր„Ҷµ䀼Ԡ䴐ĺ܀΀ؘЌ``ȐظX@x`਀xp@THtX@`|P``x@0px\0h Pt@<pp`Ph Lp0pP|Xd xHHP`@P ``@pX0 @p`p@H8P`  hXP@l08(P `p8TDl0@ `@p`H 80p`X0` P0p0pH (((XP0 0PLHHDT\2LH8hH@ H(@D(P<_,^``P\(8DDTFdDBP8pTHhN(0@H`@ 8`dllb\GVRD,F[=D15<($z,[ \K|6`A&gs[\\lx<|@XvZFzLlBL~ep|` P @h@x@0 dX6`tv\d($:0TRE=tnB':XO*lh24dC=Yx ]D|}}|{|{||}}}{{}|}}}{}}|}}}~}|}}}~}~}|||{~}||}}y}}y|}{~{}||{}|~}}z}}z}~}~}~{~}{|~~z~~|||}z}|}~||{|}}|z|}{~~}}}|{~y}}|y}|{|}||~|{}}{}|}|y{|z~}z}z~}{|}${{}|}||}{~}}z{{{|||}|||}}}|}|{||}~|{|}}~}L~}}||}||{~{}}{}|}}}{}~|z}|~{{|~}|||}||{}}{}}{|}|||z}|||}{{{}~}}}}}~}|||~~~3}{{}}|}|{||}}}|{}~~}~}{|}~}}~}}}~}~~|~~~~~|}}}|}M|~~}~}~~~|}{}~}{}}~}}{{~~||}}}~~}{||~|}~~~}}|~|~|}|}|~~}~~{~}}~}}~~}|%~~{~}~}~~|~}}|~~}}{~ ~~~~}}}~~~~}}}~~}|}}{{{}{y{{{|}{}|||}|~|~~{}||}{~}|}|z|{z~~~~}{~{{}|~~|~~~|~}}||~~~~|| ~}~|0Ppp0Ppp樠`0ppЀp p𐄠̈ؐܐؼЀ蘰؄hxа༤萐ؐȠఀ萰ఠЀШؘࠀ؀𘀀АЀ耀ЀАЌ؀茆ԬШനؔФ𬚰بЀм𸘌ȔਔЀ􊫷ڎ䟀Ƞ̐Ȝxh젌ุܡڌ`t`ࠄa@x초xPP}ࠀ@@܇tļ`zntȴxɲ@rxX`x܀ȸ@0` p`PX㨨hȀ0ppxPp`|`ȀЈ`԰Ԉ@@дȀ |pphhxpxPp`hppp`@``x茨h@`Ȅ P`pxh`xtd0XH@H`hh`@ `h`xp`p`|`X @0 ||@d\DPX\p`p`ppX| \lX`tXtxXxpjtx|Ġ@hl0p|xPphX`lxxpȀ@~xxpxlpxhĀȀ@؜航֊ޝ}쐨̰̐x𣚐`ЂؾԐ`ذ̙ڔ඙pPxxР`貐p`|쌘𐈀ȌږƜȌԴШވxరȄ`pp ppp`@h XXhhd`LHH(0| HHddLHP0PP p \0hPl`L0\H @p0``` @XP` PX80`8@@@(8@`0(hP@t\\p` H00@00 h`@P@80Hx0@`@0`@xP xX80@@( @H @x (.((pXPtHDT,@8,8(dl<64 8L`<06"J> DatXR, FLB@J$4T,,:N\4$d0x^aJ0, |z|||}|}|}}}~}{}}~}|}||~~}~}|{||~~z}}||~~}{y~}||}{}z}~{}||~}~|~~zz~~zz}~~{}}~|~{|}~~}z}{{}|}{~~||}{|}}}~{~~}}}|}|}}}{{y|{||}|}|}y~}|1}~}{z|||zz~|{{}~{|}}~}|||}||}{||{}~}{||{|||}}||{|}||{|}|{}}{|}||}}}{||}||}}~|~}}~}{~}2|}}}|~}}}||}{||}|{|}|}}||}|||}}}|{||}~}}|}||~||~~}~|{}|{||}}|}}{}}~~|~|~}}}~{{~|}~}}}~~~}}}~}}~}|~~~~}(~~}|}}~~||}}|}}|~|~~~|}~~}~|}}~|~}}|~ }~|~}}}{~~~}~}}~r}~~}~~}{}}}}}~}~}|~~~~|}}}|~~~{~|{~{}{~}~}~~}}}~}}z{}}{{|||{z|{yz{~}~~~~}}||~{{|{}~0~}}|z~|z~~~~~~~}}|~~}}~}~~|~~~~}{~~|}~~pp`x00`ȗ0`x0hx` ̈씰Ѐа𐘸̘ؠȐt@ȈИ`@|̠ȘЈЀܠДĘЀ耐ࠀ𐀀аЈ䬜Ȝ̈ȤܐЀИаАԤ踈ƞ谸𠈐Ȁ؄Ȁ𰴰ج蠸Ȩ캀ະ҆էМ|聒Ƞ̬ȃܔ|`x@ΐ̰Ȩ䒨ܘhАxȸРЌpPĀ򟉀@@䚨| h|zp~xzpz͓Ը|lX0ȌİƠ}p줖А ph`@h@@px0``x`@Ќ`ܐx쀐pĔ@hp~@xp| xp`@``pؠ@`pppx@`ؠp`xP P@xpPxx@\ht``Đ xp`xp@pPxhhHXxX@tpx`H008pl@`xph|T|txHXP@`xpPxpdPxXHxPpTxxt@0@(pȤyd0plp| h |xxX̊hЈxxx좢|`̃p|͞쁔Рʜ|ă𿠔Ҕ}~ `Ԙp؄@@И؀ԄꝆz@`@؀Ȑࢀә̨زȠҴԀ𐈰pzxؔ0Ѐ``h@ p|p lHh`X`8T 0PT8dxX`h(h@PTsX@(pX0|`DB$nt -r{CJ:{> 4 ,Pp< V=Gj (<Nk}|~~z{||}}||}{}}y|}{||}|~}|~}}~}}|{~~z}}}||~~}|y~}|y||}|~|}}|}~|||~z|}z~}|}}}}}z~}}||yz~z}~y~~}{|}B|~|}{~}}}~|}~y|}{{z{|||}|}z~|||~~}yy{{|}}{}z}}{~}}}z||z|}|}z}|}||}|}|{|}|} |}}}|{||{}{}&|~}}}|{|~|}}~~}}~~}}~|{}{}|{~}}|{|{|||}|}|}}|{}~~}|}}|~~}}~~}|{|}||}}}||}}|}~}~{~}}}~~}~}~~}|~}~}~}~~~}}|~~}}~}}~}}}~~{}|~{|~}~~}|~}~~}~} +~}~}|}~~j{~~~}{|}~}~~}~~~}}~~}~|~~~|~|~~~~~~~~~|}~~}~~}}~~}}}~}~~|~~|yz|}z|}5~~}}}|}{|~~~~{{}z}}}zz~~}~~{~|}|}~~~}||}~}}~}~~|~~}|pp@PHH@pЀظȘȨШܸРȰԀ𠀠ȈиЀРИи؀АиظЌȤЀИȀࠀЀР𘨐؀и𰐘ุ؀Рؘ踬ԨШبИĤܘؔ؜Јذ谬°ଲƠÔ讹斚`|xȎؐhxЀ@xČxҥ耘p||P 𘀪~`pvpttzpޑp@h`h`XhpPȠ|ظ@ȨxxР`@pࠔ@p`p@Ѐ``xp t؀@@@Ȉ`@@`xtxp@xpЀP`ph``PЀРИpxpxh``PPx`PxxP@ph @Px`hx`hPH0X0 0P00p@PpX@`0̐8p\dP0zp ``pxhx\ptPP` pxjpjX|İxPx@dl@xtp؆p|Ь|xpv@pt䠨PphĠИ`腺ƾ~|ܔȨꕗи~ءְ|@М@аȐ؀ࠚΠ |`̀Ѐഠ􆲝̚ƤMxxࠠȀPPlȸ`p `h@p䐀@XhpvlPHx `x @H0 `0D`P h( X@0@0@HD\`pd:x`DX PxH@0`HP`P @ PX@ ((P0(@0P@PX =0\0\8 d\8Hpx0xlpP0`P00((XlH@@@p0t\bXh@@P@@X@0X 4DPDLJ46LTHLdP@FR@2 `>2(D (HDP`d( dhD,x~tX6`0 (M`0HH,XDRD H0PRR AG29b5xo4m*@ :&D@ N[+64vWZ0.0F4z`HDxhx`tpP@@ph dU\:"TR8 }}yy{}z}|}|{|}}|y}}|}|}}||~|zz~}}}z}~|}y~}}{{}|}||}~||}~~}|z|}|~~{||}~}|}~~z}}|~}z}|z|}}{{~}}|}||y||{~}{{}zz}}}|}{|||{{|{}||{|}{{~~~}}zy|~|||{}~}~}|}z||y|}|{}||}|}||{{|}}|{}}}|}}{}}}|{|}{~}}|4}~}{~}}}|~~}~~~{~}z}}{|~{|~}{|}{||}}}||}|}}|}}~|||}|}|||}~}~}}~}~|~}}|}|}}}~}~}{|}~{{|~|~|}}}~~}~%}}|~~}~~}~~~|}|~{{~|}}|}}}|~~7~~~}~}~~~|~}~}~}}}~}|}~~|~}~~~~}}}}~~{~~ ~~~}}D}}~|~~~}~}|~}{~~}~~~~}~}~|~|~}~{~~}}~{|}~~|yz||}}{az|z}}z||}}{~}}{}{~|~|~~~{||{~~}}{~~}~~~~|~{~~~{}~~~~~~~|~pxPp0Ȁ@X0h`訠xؐШ一ؠȜȘЀ䈮p`耐ఈАxаతЀааؘИ舨ࠐరШ𠀈褀Иئ谈Ј䬘زЀҀȂԮ䰐ЀȮРȐĮٛܚ耜܀݈̂`ؐp |А`pਸ؄`tH0yx``zĢl|h`PP|Š~ƮxЀ@x h x@`@p`蠀؀Ѐxt hh@جPtP z||@x@PАpЀPpppp@`@،h`PppXxаx````hȐ` XpȰ@x@pxpXhphpȐX`XPhP`hp@XHPHP hhxd`p@pp@x̀XpH`h@lЀ@hPhp @xH@x@`X`dj`X||dxnPp0ls֌TpPtHxHXĀxl`dr؎ttpp̘xpXvPpp@耎`ƌ޼h䰆ҕzt|舠Ȃ܄̪̀@➂ĄРЀЈp`ఠРਸ0` xx܈ȐȃҰĈ蜴ԑĆδhp`XvHȈЈ`hh@`@Pp0x@x hlh(Pj\d@Hth@8` `L80(`THh0"@P ( (\\ (8|0(HPx h@H(P0P@ `P0`@A X8 X@@8XXhP @@DlP`d,dH^>X@$^L>f8 zTXdf\H DX|ZB46@Djd8|N@Hh Z$n|R0LLN<Fyt +(?.>DPLB$J"416*xj}z}~{|}z}|}||}}|~}{{{}|}}{}}{~}}~}}y|}|}}}|}}~}~{{z}|}{{~~|}~}~||}}}|}|}~}}}{~~~~{}{~~|{}||}y}z}~~|z|}y}|~}|}||}|}~}||}|3}|||}|}|}||~||~}}}~~}y|}z{{}~}{||}|{zz}}{|}}|}}}|}{|{|}|z{|{}|{|}}{|{}}|{}|{}||}}|}|}{|}~|}~}|~}~}|}~~~}|}|||}|}|}}|}||}}~~|}~}}~~}}}|~~}}~}~~~}~}~}|}{}}}{}|}~}~}~ ~|~|~~~|~}|~}~|~~~~$}}}}~~~}}~}}|}}}~~~}~~~~~~~~(|}}}~|~||~}|{~~~}|~~}~~}~~}~~~~~~{}}}~}~~~~~|~~|}}}{|~ ~~~||~}{}|~~{|}|zy|}}{|{|||{|A{}~{{}||{}~}~~{|}{|~~}}}~}{~}|~~~||~~~}}||~}~~~؈ЀxP0`x Мp@ਨpİࠀ訄иؐ訐Ѐ਀`pАЀРРȰРਠРФЀРఀР؈ȨША蘘И訨蠼ԜФʞ𢔤萘ඎЀذبȾ࢈ОƣبЀȀ֏β䮨Вүʘڮ𰼀Ѐآڌʊh zhਐ@`~زޔ~ؗԄċܝֈؤ|ԀȶXz成zph`@xX pPਠФ@آ@@hxxPМx`@tPhİ|xp𠈀@䐰ؘp``hph@p``РР@`hРਠ`ttt Ph` H@x舰@d`Xpx`Phpx@@` X `pȐ`@P8P\@XX``ppĠXhtxPx`P`@@Ht@`Pxx``8XXxԈn XXʬ\ltXppdx`{|0 t`̨ppX`Њ h `phHhԬبPp`X|`xОtpȄИxȨxҵtp̘ړֵܖܳ͘†ԫ䢮`@80ԞĂxx@`@X@x` ht~@ຌȝޛ܉Α丼ΌƦ™Ѐpఐxhpx`@PP0~`0\xTxTPXP8 80`@LP\vTH0PpH8T8 x 00x80(00P0`@ HXX` @0 |X@pP h0`p0@0`P p@hpPDDhH0h0P X(pf(0 T\^8xHYYBTJ(P<XlH 4tL\h8H@d ,Dp\ (t  P]~@_,XkN/P t$(h44J0T:__LHL6fx:B b<28rVRjL~HTX00@pph[ZxmOf^LI D` `b$2b0O`(p`bFX@&9.D&_^f*8DtTh<Jb::1=L<Hy}z}}||}{|||}{}||~|}|1}|}}~|~z~z}}{{||}|}|z|{|~~|z|{}~{z~}|}~~}|{~|}"~~}}~~}~~~||}~}~|y}|}}|{~}~~||z||}|||}||}{}|{||}}|}{}}{~}}|{y}}}||}~}}{}||}|}{||}}|}}z}}z{|{|{}~~|}~||{||}}z{|||}~{{}C|}{~}|}}}~~|~~}}}{|}||}||~{{}{}{|}|||}}}||}}}~}}~}~~}}~~~}~~}}}|}~}~}~~}|}~}|}{|}}}|}~}~|~~~}}}|~|~}}~}}~~~}|~}~}{~|}{|~|~}}}|}|}~%}|}}}~}~|~}}~}}~}~|}~~}}~}}~|}}~}}}|z}~{~~}~||Q~~~~~~|}|}~~}}}{}~||}}|{|}|||yz}{yy{{{}}}|~}{}}}{}|}~{z}|}z}{~}~~~~~~~~}ppPPHpp`ȈxxpشҲpజ쀐ఈذธ ЈఘР0ppРЄȈؤРȰИ𠠨̘ȀؠȨȠȠȀȰЎ輐蔘̂ب𤠀ȔДڨ耨ظ،蚰ؔxܠ؈̎썙䈦ޑ}谠ԫȴpఐ𐘄Ѐ ƚžLȸhzΤ䀀pzΖܰpܰt|䜺̠nh@`p``@0@pxph𐘀Ȭ̮hh`Ȑppx`p|x``pxxpph|`xРxPPXhpP``hPpXpthph`PhPdp``hppx`xPxx@p`0` Ppp`x0`PPXl`@lth`X`hd```\PPXH@`@lxxh`Xh``x`XplTlllr`\d`xPlfjP`0tdx`8t`p䴀x|pPxXdxPxp`lЎ ȐhРhhЀ`Рܠh԰x|؍|̬ЈԔČ쨰䔟P؁zΫȐpx|pЀҠǼ쨐pؘ򌚶쐠ĦҜ𮂫̜ĄШĒm @`Ppа`pЈhX0hxT`h`Pp|H0H0(px8t8@|`(`HH +>&$)F<1^^ +,:@ 4 7EJ":: F{||{|}||}||}}}|}}|}}|}Z~~}{}}{|{|||}||}{}~}z}~}|}}|}~|}}}}|~{}}~}}~}}~~|{|}~~{|~~}~}{|}||}}|}~~||{z|}}}|}||}|}}|{|{|}}|}}{{|}}~}|||}||}|}|}||}|}|}}|}}|}}|}}||{|}}}~}||}}}| }|~~}}}||}|}~}~}}|}}| }}|||{||}| }}}||}}}~~}~}~}}}~}~}|}~}}|}}|}~~~~~ }~~}~~}} +~~}}~~~~}~~|||}~~~|~|}~}~}{}~}~}}}|~c}|}~~}}}~~|}~}~~~~~~~~~~{~}}}~~~~|~}~}|}~~~~|}}~|}}~}| {||y}z~~{}~~~} ~}}{||~~|z{}~|{{}}~~~~ ~~~}}~~~|~~~ppPPHpp``Ѐ¾ࠀ`ЀȈаЎа謈pธ𠐸0xаpАЈЀЀธذИИИࠐ踰ఠؐԠиРਘАЀ؈ԼܤȄ̘จ脰𨨠คР؄ДشذȠЈxШаа԰`腼ֈ⢴Ь𼐨ܬIJܜČ斃櫿Ƥ̨`ĪР𐠸hࠠ̅خ脈Иph؀肼苾Ьp|rЈĊ@`p``@0@PXxЀxpаpаЀpp𨠘xxШPxРpx؀ȠЀxpxАЀhpPpАxx؈pPpXpȠȘx`@dphpxpxpxphxp@@@`hp`phpdPPx@`dtldptXlp```xh@PPP00@H00HttpphXXH@Xx``H`T|hd|htT\dXh~|lt\H\dppXxt``xppxxHXlజxhШx`Ȕ~а԰|섀t|̜|āԒ|Ȝ洦̺쌜䦒̏虈􂾔pΠحxpp؍Ԧ؄xlXx`Ppx܀ؤ؀̌̂Ϊؘ- @`Ppа`ppPDt@`pАЄ p8pP0@0@`r`|h0\`80hXdh0pL0PX 8H`@X8@ pP@@(@D0PP`0hhH`@ (@PPp0`0H`((0 @PPxpp@((Pq ``@0 @0xP(P8`8 8HpH0H0 DP (00PX @(H8HH04Hh8HPP8H@P@ 8 @8@0@8@(ltd``,,$@D0D@(8- 8P8(((8(@( @<0,Hx,L0\8)4JD2488HH( (00<4@@($~PiL(X4Xp000`8,( <8J(,(L2@`0 *R/ +)4xnV0Dv6f>4& 8)8<6<@8. +4`J.(Pt>VH.h4'0P 0@@0p Ht|ZUI4'& @LP0P *t +BHDH'B*\7GF $(.@@VX*Bt{||{| +}||}||}}}|}| +}~~}}||}|}|||{||}~}|}~~}}}~|}}~}~}||}~~}|}~}~}|}}|||}||}~{|}}}|} |}}}~}|}|}{{|}}~}|||}||}||}|}||}||}|||}}|}}|}}|}}||{|||{|}|}|}|~~}}}|}}|}~}~}|}}||}||}}|||{{|}||}|}}~}}|}~~}~}|} |}|}||}||} ~}~}}|}}|}}|}~~}}~~~~~~ +}}~}|}|}}}~~}~}~}~~}|}}}~}}~}~~~}}}~~|}~~}~~~}}}~~~~~~~~~ ~~~}} +}~}{~{}{|}|{{|}|~ ~~}~}~}||}}}~'||~}|z{}}}}}~~}|}~~~~}~~~~~~~~~~ \ No newline at end of file diff --git a/tests/fixtures/formats/sample.heic b/tests/fixtures/formats/sample.heic new file mode 100644 index 00000000..00cc549c Binary files /dev/null and b/tests/fixtures/formats/sample.heic differ diff --git a/tests/fixtures/formats/sample.heif b/tests/fixtures/formats/sample.heif new file mode 100644 index 00000000..7a68f35d Binary files /dev/null and b/tests/fixtures/formats/sample.heif differ diff --git a/tests/fixtures/formats/sample.ico b/tests/fixtures/formats/sample.ico new file mode 100644 index 00000000..147bdcbd Binary files /dev/null and b/tests/fixtures/formats/sample.ico differ diff --git a/tests/fixtures/formats/sample.jpg b/tests/fixtures/formats/sample.jpg new file mode 100644 index 00000000..ff2d4ebf Binary files /dev/null and b/tests/fixtures/formats/sample.jpg differ diff --git a/tests/fixtures/formats/sample.jxl b/tests/fixtures/formats/sample.jxl new file mode 100644 index 00000000..aaeef0b8 Binary files /dev/null and b/tests/fixtures/formats/sample.jxl differ diff --git a/tests/fixtures/formats/sample.png b/tests/fixtures/formats/sample.png new file mode 100644 index 00000000..1d69b1ea Binary files /dev/null and b/tests/fixtures/formats/sample.png differ diff --git a/tests/fixtures/formats/sample.psd b/tests/fixtures/formats/sample.psd new file mode 100644 index 00000000..635667de Binary files /dev/null and b/tests/fixtures/formats/sample.psd differ diff --git a/tests/fixtures/formats/sample.svg b/tests/fixtures/formats/sample.svg new file mode 100644 index 00000000..bce3ef22 --- /dev/null +++ b/tests/fixtures/formats/sample.svg @@ -0,0 +1,1707 @@ + + + + +Created by potrace 1.14, written by Peter Selinger 2001-2017 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/fixtures/formats/sample.tga b/tests/fixtures/formats/sample.tga new file mode 100644 index 00000000..55fb8866 Binary files /dev/null and b/tests/fixtures/formats/sample.tga differ diff --git a/tests/fixtures/formats/sample.tiff b/tests/fixtures/formats/sample.tiff new file mode 100644 index 00000000..417e3955 Binary files /dev/null and b/tests/fixtures/formats/sample.tiff differ diff --git a/tests/fixtures/formats/sample.webp b/tests/fixtures/formats/sample.webp new file mode 100644 index 00000000..cc735b96 Binary files /dev/null and b/tests/fixtures/formats/sample.webp differ diff --git a/tests/integration/adversarial.test.ts b/tests/integration/adversarial.test.ts new file mode 100644 index 00000000..f88c0f32 --- /dev/null +++ b/tests/integration/adversarial.test.ts @@ -0,0 +1,364 @@ +/** + * Adversarial integration tests for the ashim image API. + * + * Tests malicious and stress inputs: corrupted images, truncated data, + * oversized images, invalid dimensions, injection attacks, and malformed + * multipart payloads. + */ + +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import sharp from "sharp"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { buildTestApp, createMultipartPayload, loginAsAdmin, type TestApp } from "./test-server.js"; + +// --------------------------------------------------------------------------- +// Fixtures +// --------------------------------------------------------------------------- +const FIXTURES = join(__dirname, "..", "fixtures"); +const PNG_200x150 = readFileSync(join(FIXTURES, "test-200x150.png")); + +// --------------------------------------------------------------------------- +// Shared state +// --------------------------------------------------------------------------- +let testApp: TestApp; +let app: TestApp["app"]; +let adminToken: string; + +beforeAll(async () => { + testApp = await buildTestApp(); + app = testApp.app; + adminToken = await loginAsAdmin(app); +}, 30_000); + +afterAll(async () => { + await testApp.cleanup(); +}, 10_000); + +/** Helper to POST a multipart payload to a tool endpoint. */ +function postTool( + toolId: string, + fields: Array<{ + name: string; + filename?: string; + contentType?: string; + content: Buffer | string; + }>, +) { + const { body, contentType } = createMultipartPayload(fields); + return app.inject({ + method: "POST", + url: `/api/v1/tools/${toolId}`, + headers: { "content-type": contentType, authorization: `Bearer ${adminToken}` }, + body, + }); +} + +// ═══════════════════════════════════════════════════════════════════════════ +// CORRUPTED / TRUNCATED IMAGE DATA +// ═══════════════════════════════════════════════════════════════════════════ +describe("Corrupted and truncated images", () => { + it("rejects JPEG magic bytes followed by garbage", async () => { + // Valid JPEG header (FF D8 FF E0) then random garbage + const corruptedJpeg = Buffer.concat([ + Buffer.from([0xff, 0xd8, 0xff, 0xe0]), + Buffer.from("this is not a real jpeg image at all, just garbage data"), + ]); + + const res = await postTool("resize", [ + { name: "file", filename: "corrupt.jpg", content: corruptedJpeg, contentType: "image/jpeg" }, + { name: "settings", content: JSON.stringify({ width: 100 }) }, + ]); + + // Should fail gracefully — 400 (validation) or 422 (processing) + expect([400, 422]).toContain(res.statusCode); + const json = JSON.parse(res.body); + expect(json.error).toBeDefined(); + }); + + it("rejects a truncated PNG (first 100 bytes only)", async () => { + const truncated = PNG_200x150.subarray(0, 100); + + const res = await postTool("resize", [ + { name: "file", filename: "truncated.png", content: truncated, contentType: "image/png" }, + { name: "settings", content: JSON.stringify({ width: 50 }) }, + ]); + + // A truncated PNG still has valid magic bytes and Sharp can read partial + // metadata, so validation passes. Processing may succeed (partial decode) + // or fail gracefully. The key assertion: the server does not crash. + expect([200, 400, 422]).toContain(res.statusCode); + }); + + it("rejects random binary garbage as an image", async () => { + const garbage = Buffer.from( + Array.from({ length: 2048 }, () => Math.floor(Math.random() * 256)), + ); + + const res = await postTool("resize", [ + { name: "file", filename: "garbage.png", content: garbage, contentType: "image/png" }, + { name: "settings", content: JSON.stringify({ width: 100 }) }, + ]); + + expect([400, 422]).toContain(res.statusCode); + }); +}); + +// ═══════════════════════════════════════════════════════════════════════════ +// OVERSIZED IMAGE (MAX_MEGAPIXELS) +// ═══════════════════════════════════════════════════════════════════════════ +describe("Image exceeding MAX_MEGAPIXELS", () => { + it("rejects an image exceeding the 100MP limit", async () => { + // Test env has MAX_MEGAPIXELS=100. A 10001x10001 image = ~100.02MP. + const huge = await sharp({ + create: { + width: 10001, + height: 10001, + channels: 3, + background: { r: 0, g: 0, b: 0 }, + }, + }) + .png({ compressionLevel: 9 }) + .toBuffer(); + + const res = await postTool("resize", [ + { name: "file", filename: "huge.png", content: huge, contentType: "image/png" }, + { name: "settings", content: JSON.stringify({ width: 100 }) }, + ]); + + expect(res.statusCode).toBe(400); + const json = JSON.parse(res.body); + expect(json.error).toMatch(/exceeds maximum size/i); + }, 60_000); +}); + +// ═══════════════════════════════════════════════════════════════════════════ +// INVALID DIMENSIONS IN SETTINGS +// ═══════════════════════════════════════════════════════════════════════════ +describe("Invalid dimensions in settings", () => { + it("rejects negative width", async () => { + const res = await postTool("resize", [ + { name: "file", filename: "test.png", content: PNG_200x150, contentType: "image/png" }, + { name: "settings", content: JSON.stringify({ width: -1 }) }, + ]); + + expect(res.statusCode).toBe(400); + const json = JSON.parse(res.body); + expect(json.error).toMatch(/invalid settings/i); + }); + + it("rejects negative height", async () => { + const res = await postTool("resize", [ + { name: "file", filename: "test.png", content: PNG_200x150, contentType: "image/png" }, + { name: "settings", content: JSON.stringify({ height: -1 }) }, + ]); + + expect(res.statusCode).toBe(400); + }); + + it("rejects zero width", async () => { + const res = await postTool("resize", [ + { name: "file", filename: "test.png", content: PNG_200x150, contentType: "image/png" }, + { name: "settings", content: JSON.stringify({ width: 0 }) }, + ]); + + // z.number().positive() rejects 0 + expect(res.statusCode).toBe(400); + }); + + it("handles float dimensions", async () => { + const res = await postTool("resize", [ + { name: "file", filename: "test.png", content: PNG_200x150, contentType: "image/png" }, + { name: "settings", content: JSON.stringify({ width: 1.5 }) }, + ]); + + // Zod allows floats through z.number().positive() — Sharp will round + // Should succeed or get rejected by Zod, but not crash + expect([200, 400, 422]).toContain(res.statusCode); + }); + + it("rejects extremely large width with 422", async () => { + // Use a large-but-not-absurd value so Sharp rejects it quickly + // rather than trying to allocate gigabytes of RAM and timing out. + const res = await postTool("resize", [ + { name: "file", filename: "test.png", content: PNG_200x150, contentType: "image/png" }, + { name: "settings", content: JSON.stringify({ width: 65536 }) }, + ]); + + // Sharp should fail with a processing error for dimensions this large + // on a tiny input image, or succeed if it can handle it. Must not crash. + expect([200, 400, 422]).toContain(res.statusCode); + }); +}); + +// ═══════════════════════════════════════════════════════════════════════════ +// INJECTION ATTACKS IN TEXT FIELDS +// ═══════════════════════════════════════════════════════════════════════════ +describe("Injection attacks in text fields", () => { + it("SQL injection in text-overlay does not affect database", async () => { + const res = await postTool("text-overlay", [ + { name: "file", filename: "test.png", content: PNG_200x150, contentType: "image/png" }, + { + name: "settings", + content: JSON.stringify({ + text: "'; DROP TABLE users; --", + fontSize: 24, + }), + }, + ]); + + // Text overlay should process normally — SQL injection is in SVG text + expect(res.statusCode).toBe(200); + + // Verify the database is still intact by making an auth request + const healthRes = await app.inject({ + method: "GET", + url: "/api/v1/health", + }); + expect(healthRes.statusCode).toBe(200); + }); + + it("XSS payload in text-overlay is treated as literal text", async () => { + const res = await postTool("text-overlay", [ + { name: "file", filename: "test.png", content: PNG_200x150, contentType: "image/png" }, + { + name: "settings", + content: JSON.stringify({ + text: "", + fontSize: 24, + }), + }, + ]); + + // The escapeXml function should sanitize angle brackets + expect(res.statusCode).toBe(200); + }); +}); + +// ═══════════════════════════════════════════════════════════════════════════ +// MALFORMED SETTINGS +// ═══════════════════════════════════════════════════════════════════════════ +describe("Malformed settings field", () => { + it("rejects non-JSON settings string with 400", async () => { + const res = await postTool("resize", [ + { name: "file", filename: "test.png", content: PNG_200x150, contentType: "image/png" }, + { name: "settings", content: "not json at all" }, + ]); + + expect(res.statusCode).toBe(400); + const json = JSON.parse(res.body); + expect(json.error).toMatch(/json/i); + }); + + it("rejects settings as JSON array with 400", async () => { + const res = await postTool("resize", [ + { name: "file", filename: "test.png", content: PNG_200x150, contentType: "image/png" }, + { name: "settings", content: "[]" }, + ]); + + // Zod object schema should reject an array + expect(res.statusCode).toBe(400); + }); + + it("rejects settings as JSON string with 400", async () => { + const res = await postTool("resize", [ + { name: "file", filename: "test.png", content: PNG_200x150, contentType: "image/png" }, + { name: "settings", content: '"just a string"' }, + ]); + + expect(res.statusCode).toBe(400); + }); + + it("rejects settings as JSON number with 400", async () => { + const res = await postTool("resize", [ + { name: "file", filename: "test.png", content: PNG_200x150, contentType: "image/png" }, + { name: "settings", content: "42" }, + ]); + + expect(res.statusCode).toBe(400); + }); +}); + +// ═══════════════════════════════════════════════════════════════════════════ +// DUPLICATE FILE FIELDS +// ═══════════════════════════════════════════════════════════════════════════ +describe("Duplicate file fields in multipart", () => { + it("rejects a request with multiple file parts", async () => { + const { body, contentType } = createMultipartPayload([ + { name: "file", filename: "first.png", content: PNG_200x150, contentType: "image/png" }, + { name: "file", filename: "second.png", content: PNG_200x150, contentType: "image/png" }, + { name: "settings", content: JSON.stringify({ width: 100 }) }, + ]); + + const res = await app.inject({ + method: "POST", + url: "/api/v1/tools/resize", + headers: { "content-type": contentType, authorization: `Bearer ${adminToken}` }, + body, + }); + + // Tool factory rejects multiple file parts with 400 + expect(res.statusCode).toBe(400); + const json = JSON.parse(res.body); + expect(json.error).toMatch(/one image at a time/i); + }); +}); + +// ═══════════════════════════════════════════════════════════════════════════ +// BINARY GARBAGE IN SETTINGS FIELD +// ═══════════════════════════════════════════════════════════════════════════ +describe("Binary data in settings field", () => { + it("rejects random bytes as settings with 400", async () => { + const randomBytes = Buffer.from( + Array.from({ length: 64 }, () => Math.floor(Math.random() * 256)), + ); + + const res = await postTool("resize", [ + { name: "file", filename: "test.png", content: PNG_200x150, contentType: "image/png" }, + { name: "settings", content: randomBytes.toString("binary") }, + ]); + + // Should return 400 for invalid JSON + expect(res.statusCode).toBe(400); + }); +}); + +// ═══════════════════════════════════════════════════════════════════════════ +// AUTH — UNAUTHENTICATED ACCESS +// ═══════════════════════════════════════════════════════════════════════════ +describe("Unauthenticated tool access", () => { + it("rejects tool requests without auth token", async () => { + const { body, contentType } = createMultipartPayload([ + { name: "file", filename: "test.png", content: PNG_200x150, contentType: "image/png" }, + { name: "settings", content: JSON.stringify({ width: 100 }) }, + ]); + + const res = await app.inject({ + method: "POST", + url: "/api/v1/tools/resize", + headers: { "content-type": contentType }, + body, + }); + + expect(res.statusCode).toBe(401); + }); + + it("rejects tool requests with invalid auth token", async () => { + const { body, contentType } = createMultipartPayload([ + { name: "file", filename: "test.png", content: PNG_200x150, contentType: "image/png" }, + { name: "settings", content: JSON.stringify({ width: 100 }) }, + ]); + + const res = await app.inject({ + method: "POST", + url: "/api/v1/tools/resize", + headers: { + "content-type": contentType, + authorization: "Bearer totally-fake-token-12345", + }, + body, + }); + + expect(res.statusCode).toBe(401); + }); +}); diff --git a/tests/integration/barcode-read.test.ts b/tests/integration/barcode-read.test.ts new file mode 100644 index 00000000..ca9d5274 --- /dev/null +++ b/tests/integration/barcode-read.test.ts @@ -0,0 +1,257 @@ +/** + * Integration tests for the barcode-read tool (/api/v1/tools/barcode-read). + * + * Covers barcode/QR code detection from images, annotated image generation, + * graceful handling of images without barcodes, and input validation. + * + * Uses a round-trip approach: generates a QR code via qr-generate, then reads + * it back with barcode-read, guaranteeing a clean, machine-readable input. + */ + +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import sharp from "sharp"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { buildTestApp, createMultipartPayload, loginAsAdmin, type TestApp } from "./test-server.js"; + +const FIXTURES = join(__dirname, "..", "fixtures"); +const PLAIN_PNG = readFileSync(join(FIXTURES, "test-200x150.png")); + +let testApp: TestApp; +let app: TestApp["app"]; +let adminToken: string; + +/** QR code PNG buffer generated once in beforeAll. */ +let qrCodePng: Buffer; +const QR_TEXT = "https://example.com/test-barcode-read"; + +beforeAll(async () => { + testApp = await buildTestApp(); + app = testApp.app; + adminToken = await loginAsAdmin(app); + + // Generate a QR code to use as a reliable fixture + const genRes = await app.inject({ + method: "POST", + url: "/api/v1/tools/qr-generate", + headers: { + authorization: `Bearer ${adminToken}`, + "content-type": "application/json", + }, + payload: { text: QR_TEXT, size: 400 }, + }); + const genResult = JSON.parse(genRes.body); + const dlRes = await app.inject({ + method: "GET", + url: genResult.downloadUrl, + }); + qrCodePng = dlRes.rawPayload; +}, 30_000); + +afterAll(async () => { + await testApp.cleanup(); +}, 10_000); + +describe("Barcode Read", () => { + it("detects and decodes a QR code from a generated image", async () => { + const { body, contentType } = createMultipartPayload([ + { name: "file", filename: "qr.png", contentType: "image/png", content: qrCodePng }, + ]); + + const res = await app.inject({ + method: "POST", + url: "/api/v1/tools/barcode-read", + headers: { + authorization: `Bearer ${adminToken}`, + "content-type": contentType, + }, + body, + }); + + expect(res.statusCode).toBe(200); + const result = JSON.parse(res.body); + + expect(result.filename).toBe("qr.png"); + expect(result.barcodes).toBeDefined(); + expect(Array.isArray(result.barcodes)).toBe(true); + expect(result.barcodes.length).toBeGreaterThanOrEqual(1); + + const barcode = result.barcodes[0]; + expect(barcode.type).toBeDefined(); + expect(barcode.text).toBe(QR_TEXT); + }); + + it("returns position data for detected barcodes", async () => { + const { body, contentType } = createMultipartPayload([ + { name: "file", filename: "qr.png", contentType: "image/png", content: qrCodePng }, + ]); + + const res = await app.inject({ + method: "POST", + url: "/api/v1/tools/barcode-read", + headers: { + authorization: `Bearer ${adminToken}`, + "content-type": contentType, + }, + body, + }); + + expect(res.statusCode).toBe(200); + const result = JSON.parse(res.body); + + expect(result.barcodes.length).toBeGreaterThanOrEqual(1); + + const barcode = result.barcodes[0]; + expect(barcode.position).toBeDefined(); + expect(barcode.position.topLeft).toBeDefined(); + expect(barcode.position.topRight).toBeDefined(); + expect(barcode.position.bottomLeft).toBeDefined(); + expect(barcode.position.bottomRight).toBeDefined(); + + expect(typeof barcode.position.topLeft.x).toBe("number"); + expect(typeof barcode.position.topLeft.y).toBe("number"); + }); + + it("generates a downloadable annotated image when barcodes are found", async () => { + const { body, contentType } = createMultipartPayload([ + { name: "file", filename: "qr.png", contentType: "image/png", content: qrCodePng }, + ]); + + const res = await app.inject({ + method: "POST", + url: "/api/v1/tools/barcode-read", + headers: { + authorization: `Bearer ${adminToken}`, + "content-type": contentType, + }, + body, + }); + + expect(res.statusCode).toBe(200); + const result = JSON.parse(res.body); + + expect(result.annotatedUrl).toBeDefined(); + expect(result.annotatedUrl).not.toBeNull(); + expect(result.previewUrl).toBeDefined(); + + // Download the annotated image + const dlRes = await app.inject({ + method: "GET", + url: result.annotatedUrl, + }); + + expect(dlRes.statusCode).toBe(200); + const meta = await sharp(dlRes.rawPayload).metadata(); + expect(meta.format).toBe("png"); + expect(meta.width).toBeGreaterThan(0); + expect(meta.height).toBeGreaterThan(0); + }); + + it("handles images with no barcodes gracefully", async () => { + const { body, contentType } = createMultipartPayload([ + { name: "file", filename: "plain.png", contentType: "image/png", content: PLAIN_PNG }, + ]); + + const res = await app.inject({ + method: "POST", + url: "/api/v1/tools/barcode-read", + headers: { + authorization: `Bearer ${adminToken}`, + "content-type": contentType, + }, + body, + }); + + expect(res.statusCode).toBe(200); + const result = JSON.parse(res.body); + + expect(result.barcodes).toBeDefined(); + expect(result.barcodes).toHaveLength(0); + expect(result.annotatedUrl).toBeNull(); + expect(result.previewUrl).toBeNull(); + }); + + it("returns all expected fields in the response", async () => { + const { body, contentType } = createMultipartPayload([ + { name: "file", filename: "qr.png", contentType: "image/png", content: qrCodePng }, + ]); + + const res = await app.inject({ + method: "POST", + url: "/api/v1/tools/barcode-read", + headers: { + authorization: `Bearer ${adminToken}`, + "content-type": contentType, + }, + body, + }); + + expect(res.statusCode).toBe(200); + const result = JSON.parse(res.body); + + expect(result).toHaveProperty("filename"); + expect(result).toHaveProperty("barcodes"); + expect(result).toHaveProperty("annotatedUrl"); + expect(result).toHaveProperty("previewUrl"); + }); + + it("reads barcodes from AVIF content fixtures when detectable", async () => { + // AVIF fixtures may or may not contain machine-readable barcodes depending + // on image quality. This test verifies the route handles AVIF input without + // errors regardless of detection outcome. + const avifBarcode = readFileSync(join(FIXTURES, "content", "barcode.avif")); + const { body, contentType } = createMultipartPayload([ + { name: "file", filename: "barcode.avif", contentType: "image/avif", content: avifBarcode }, + ]); + + const res = await app.inject({ + method: "POST", + url: "/api/v1/tools/barcode-read", + headers: { + authorization: `Bearer ${adminToken}`, + "content-type": contentType, + }, + body, + }); + + expect(res.statusCode).toBe(200); + const result = JSON.parse(res.body); + expect(result.filename).toBe("barcode.avif"); + expect(Array.isArray(result.barcodes)).toBe(true); + }); + + // ── Validation ────────────────────────────────────────────────────── + + it("rejects requests without a file", async () => { + const { body, contentType } = createMultipartPayload([{ name: "dummy", content: "nothing" }]); + + const res = await app.inject({ + method: "POST", + url: "/api/v1/tools/barcode-read", + headers: { + authorization: `Bearer ${adminToken}`, + "content-type": contentType, + }, + body, + }); + + expect(res.statusCode).toBe(400); + const result = JSON.parse(res.body); + expect(result.error).toMatch(/no image/i); + }); + + it("rejects unauthenticated requests", async () => { + const { body, contentType } = createMultipartPayload([ + { name: "file", filename: "qr.png", contentType: "image/png", content: qrCodePng }, + ]); + + const res = await app.inject({ + method: "POST", + url: "/api/v1/tools/barcode-read", + headers: { "content-type": contentType }, + body, + }); + + expect(res.statusCode).toBe(401); + }); +}); diff --git a/tests/integration/border.test.ts b/tests/integration/border.test.ts new file mode 100644 index 00000000..6986410d --- /dev/null +++ b/tests/integration/border.test.ts @@ -0,0 +1,366 @@ +/** + * Integration tests for the border tool (/api/v1/tools/border). + * + * Covers solid-color borders, padding, corner radius, shadow, dimension + * verification, and input validation. + */ + +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import sharp from "sharp"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { buildTestApp, createMultipartPayload, loginAsAdmin, type TestApp } from "./test-server.js"; + +const FIXTURES = join(__dirname, "..", "fixtures"); +const PNG = readFileSync(join(FIXTURES, "test-200x150.png")); +const JPG = readFileSync(join(FIXTURES, "test-100x100.jpg")); + +let testApp: TestApp; +let app: TestApp["app"]; +let adminToken: string; + +beforeAll(async () => { + testApp = await buildTestApp(); + app = testApp.app; + adminToken = await loginAsAdmin(app); +}, 30_000); + +afterAll(async () => { + await testApp.cleanup(); +}, 10_000); + +describe("Border", () => { + it("adds a solid black border with default width", async () => { + const { body, contentType } = createMultipartPayload([ + { name: "file", filename: "test.png", contentType: "image/png", content: PNG }, + { + name: "settings", + content: JSON.stringify({ borderWidth: 10, borderColor: "#000000" }), + }, + ]); + + const res = await app.inject({ + method: "POST", + url: "/api/v1/tools/border", + headers: { + authorization: `Bearer ${adminToken}`, + "content-type": contentType, + }, + body, + }); + + expect(res.statusCode).toBe(200); + const result = JSON.parse(res.body); + expect(result.downloadUrl).toBeDefined(); + expect(result.processedSize).toBeGreaterThan(0); + }); + + it("produces correct dimensions with a 20px border", async () => { + const borderWidth = 20; + const { body, contentType } = createMultipartPayload([ + { name: "file", filename: "test.png", contentType: "image/png", content: PNG }, + { + name: "settings", + content: JSON.stringify({ borderWidth, borderColor: "#FF0000" }), + }, + ]); + + const res = await app.inject({ + method: "POST", + url: "/api/v1/tools/border", + headers: { + authorization: `Bearer ${adminToken}`, + "content-type": contentType, + }, + body, + }); + + expect(res.statusCode).toBe(200); + const result = JSON.parse(res.body); + + // Download the result and verify dimensions + const dlRes = await app.inject({ + method: "GET", + url: result.downloadUrl, + headers: { authorization: `Bearer ${adminToken}` }, + }); + expect(dlRes.statusCode).toBe(200); + + const meta = await sharp(dlRes.rawPayload).metadata(); + // Original 200x150 + 20px border on each side = 240x190 + expect(meta.width).toBe(200 + borderWidth * 2); + expect(meta.height).toBe(150 + borderWidth * 2); + }); + + it("adds padding and border together with correct dimensions", async () => { + const borderWidth = 10; + const padding = 5; + const { body, contentType } = createMultipartPayload([ + { name: "file", filename: "test.png", contentType: "image/png", content: PNG }, + { + name: "settings", + content: JSON.stringify({ + borderWidth, + borderColor: "#0000FF", + padding, + paddingColor: "#FFFFFF", + }), + }, + ]); + + const res = await app.inject({ + method: "POST", + url: "/api/v1/tools/border", + headers: { + authorization: `Bearer ${adminToken}`, + "content-type": contentType, + }, + body, + }); + + expect(res.statusCode).toBe(200); + const result = JSON.parse(res.body); + + const dlRes = await app.inject({ + method: "GET", + url: result.downloadUrl, + headers: { authorization: `Bearer ${adminToken}` }, + }); + const meta = await sharp(dlRes.rawPayload).metadata(); + // Original 200x150 + 5px padding each side + 10px border each side = 230x180 + expect(meta.width).toBe(200 + padding * 2 + borderWidth * 2); + expect(meta.height).toBe(150 + padding * 2 + borderWidth * 2); + }); + + it("applies corner radius", async () => { + const { body, contentType } = createMultipartPayload([ + { name: "file", filename: "test.png", contentType: "image/png", content: PNG }, + { + name: "settings", + content: JSON.stringify({ + borderWidth: 10, + borderColor: "#000000", + cornerRadius: 20, + }), + }, + ]); + + const res = await app.inject({ + method: "POST", + url: "/api/v1/tools/border", + headers: { + authorization: `Bearer ${adminToken}`, + "content-type": contentType, + }, + body, + }); + + expect(res.statusCode).toBe(200); + const result = JSON.parse(res.body); + expect(result.processedSize).toBeGreaterThan(0); + + // Verify output is valid PNG (corner radius forces alpha channel) + const dlRes = await app.inject({ + method: "GET", + url: result.downloadUrl, + headers: { authorization: `Bearer ${adminToken}` }, + }); + const meta = await sharp(dlRes.rawPayload).metadata(); + expect(meta.format).toBe("png"); + expect(meta.channels).toBe(4); // alpha channel from rounded corners + }); + + it("applies shadow effect", async () => { + const { body, contentType } = createMultipartPayload([ + { name: "file", filename: "test.png", contentType: "image/png", content: PNG }, + { + name: "settings", + content: JSON.stringify({ + borderWidth: 5, + borderColor: "#333333", + shadow: true, + shadowBlur: 10, + shadowOffsetX: 3, + shadowOffsetY: 3, + shadowColor: "#000000", + shadowOpacity: 50, + }), + }, + ]); + + const res = await app.inject({ + method: "POST", + url: "/api/v1/tools/border", + headers: { + authorization: `Bearer ${adminToken}`, + "content-type": contentType, + }, + body, + }); + + expect(res.statusCode).toBe(200); + const result = JSON.parse(res.body); + + // Shadow adds extra canvas around the image, so output should be larger + const dlRes = await app.inject({ + method: "GET", + url: result.downloadUrl, + headers: { authorization: `Bearer ${adminToken}` }, + }); + const meta = await sharp(dlRes.rawPayload).metadata(); + // Image + border + shadow spread means larger than original + border + expect(meta.width!).toBeGreaterThan(200 + 5 * 2); + expect(meta.height!).toBeGreaterThan(150 + 5 * 2); + }); + + it("works with JPEG input", async () => { + const { body, contentType } = createMultipartPayload([ + { name: "file", filename: "test.jpg", contentType: "image/jpeg", content: JPG }, + { + name: "settings", + content: JSON.stringify({ borderWidth: 15, borderColor: "#00FF00" }), + }, + ]); + + const res = await app.inject({ + method: "POST", + url: "/api/v1/tools/border", + headers: { + authorization: `Bearer ${adminToken}`, + "content-type": contentType, + }, + body, + }); + + expect(res.statusCode).toBe(200); + const result = JSON.parse(res.body); + + const dlRes = await app.inject({ + method: "GET", + url: result.downloadUrl, + headers: { authorization: `Bearer ${adminToken}` }, + }); + const meta = await sharp(dlRes.rawPayload).metadata(); + expect(meta.width).toBe(100 + 15 * 2); + expect(meta.height).toBe(100 + 15 * 2); + }); + + it("uses default settings when none provided", async () => { + const { body, contentType } = createMultipartPayload([ + { name: "file", filename: "test.png", contentType: "image/png", content: PNG }, + { name: "settings", content: JSON.stringify({}) }, + ]); + + const res = await app.inject({ + method: "POST", + url: "/api/v1/tools/border", + headers: { + authorization: `Bearer ${adminToken}`, + "content-type": contentType, + }, + body, + }); + + expect(res.statusCode).toBe(200); + const result = JSON.parse(res.body); + + // Default borderWidth is 10, so dimensions should be 220x170 + const dlRes = await app.inject({ + method: "GET", + url: result.downloadUrl, + headers: { authorization: `Bearer ${adminToken}` }, + }); + const meta = await sharp(dlRes.rawPayload).metadata(); + expect(meta.width).toBe(220); + expect(meta.height).toBe(170); + }); + + // ── Validation ────────────────────────────────────────────────────── + + it("rejects requests without a file", async () => { + const { body, contentType } = createMultipartPayload([ + { + name: "settings", + content: JSON.stringify({ borderWidth: 10, borderColor: "#000000" }), + }, + ]); + + const res = await app.inject({ + method: "POST", + url: "/api/v1/tools/border", + headers: { + authorization: `Bearer ${adminToken}`, + "content-type": contentType, + }, + body, + }); + + expect(res.statusCode).toBe(400); + const result = JSON.parse(res.body); + expect(result.error).toMatch(/no image/i); + }); + + it("rejects invalid border color format", async () => { + const { body, contentType } = createMultipartPayload([ + { name: "file", filename: "test.png", contentType: "image/png", content: PNG }, + { + name: "settings", + content: JSON.stringify({ borderWidth: 10, borderColor: "red" }), + }, + ]); + + const res = await app.inject({ + method: "POST", + url: "/api/v1/tools/border", + headers: { + authorization: `Bearer ${adminToken}`, + "content-type": contentType, + }, + body, + }); + + expect(res.statusCode).toBe(400); + const result = JSON.parse(res.body); + expect(result.error).toMatch(/invalid settings/i); + }); + + it("rejects border width exceeding max", async () => { + const { body, contentType } = createMultipartPayload([ + { name: "file", filename: "test.png", contentType: "image/png", content: PNG }, + { + name: "settings", + content: JSON.stringify({ borderWidth: 3000, borderColor: "#000000" }), + }, + ]); + + const res = await app.inject({ + method: "POST", + url: "/api/v1/tools/border", + headers: { + authorization: `Bearer ${adminToken}`, + "content-type": contentType, + }, + body, + }); + + expect(res.statusCode).toBe(400); + const result = JSON.parse(res.body); + expect(result.error).toMatch(/invalid settings/i); + }); + + it("rejects unauthenticated requests", async () => { + const { body, contentType } = createMultipartPayload([ + { name: "file", filename: "test.png", contentType: "image/png", content: PNG }, + { name: "settings", content: JSON.stringify({ borderWidth: 10 }) }, + ]); + + const res = await app.inject({ + method: "POST", + url: "/api/v1/tools/border", + headers: { "content-type": contentType }, + body, + }); + + expect(res.statusCode).toBe(401); + }); +}); diff --git a/tests/integration/bulk-rename.test.ts b/tests/integration/bulk-rename.test.ts new file mode 100644 index 00000000..bde925a6 --- /dev/null +++ b/tests/integration/bulk-rename.test.ts @@ -0,0 +1,235 @@ +/** + * Integration tests for the bulk-rename tool (/api/v1/tools/bulk-rename). + * + * Covers pattern-based renaming with {{index}}, {{padded}}, {{original}} + * placeholders, custom start index, ZIP response format, and input validation. + */ + +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import AdmZip from "adm-zip"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { buildTestApp, createMultipartPayload, loginAsAdmin, type TestApp } from "./test-server.js"; + +const FIXTURES = join(__dirname, "..", "fixtures"); +const PNG = readFileSync(join(FIXTURES, "test-200x150.png")); +const JPG = readFileSync(join(FIXTURES, "test-100x100.jpg")); +const WEBP = readFileSync(join(FIXTURES, "test-50x50.webp")); + +/** Extract sorted filenames from a ZIP buffer. */ +function zipEntryNames(buf: Buffer): string[] { + const zip = new AdmZip(buf); + return zip + .getEntries() + .map((e) => e.entryName) + .sort(); +} + +let testApp: TestApp; +let app: TestApp["app"]; +let adminToken: string; + +beforeAll(async () => { + testApp = await buildTestApp(); + app = testApp.app; + adminToken = await loginAsAdmin(app); +}, 30_000); + +afterAll(async () => { + await testApp.cleanup(); +}, 10_000); + +describe("Bulk Rename", () => { + it("renames files using the default pattern and returns a ZIP", async () => { + const { body, contentType } = createMultipartPayload([ + { name: "file", filename: "photo.png", contentType: "image/png", content: PNG }, + { name: "file", filename: "pic.jpg", contentType: "image/jpeg", content: JPG }, + { name: "settings", content: JSON.stringify({}) }, + ]); + + const res = await app.inject({ + method: "POST", + url: "/api/v1/tools/bulk-rename", + headers: { + authorization: `Bearer ${adminToken}`, + "content-type": contentType, + }, + body, + }); + + expect(res.statusCode).toBe(200); + expect(res.headers["content-type"]).toBe("application/zip"); + + const filenames = zipEntryNames(res.rawPayload); + + // Default pattern is "image-{{index}}", starting at 1 + expect(filenames).toHaveLength(2); + expect(filenames).toContain("image-1.png"); + expect(filenames).toContain("image-2.jpg"); + }); + + it("renames files using a custom pattern with {{index}}", async () => { + const { body, contentType } = createMultipartPayload([ + { name: "file", filename: "a.png", contentType: "image/png", content: PNG }, + { name: "file", filename: "b.jpg", contentType: "image/jpeg", content: JPG }, + { name: "settings", content: JSON.stringify({ pattern: "photo-{{index}}" }) }, + ]); + + const res = await app.inject({ + method: "POST", + url: "/api/v1/tools/bulk-rename", + headers: { + authorization: `Bearer ${adminToken}`, + "content-type": contentType, + }, + body, + }); + + expect(res.statusCode).toBe(200); + + const filenames = zipEntryNames(res.rawPayload); + expect(filenames).toContain("photo-1.png"); + expect(filenames).toContain("photo-2.jpg"); + }); + + it("renames files using {{padded}} placeholder", async () => { + const { body, contentType } = createMultipartPayload([ + { name: "file", filename: "a.png", contentType: "image/png", content: PNG }, + { name: "file", filename: "b.jpg", contentType: "image/jpeg", content: JPG }, + { name: "file", filename: "c.webp", contentType: "image/webp", content: WEBP }, + { name: "settings", content: JSON.stringify({ pattern: "img-{{padded}}" }) }, + ]); + + const res = await app.inject({ + method: "POST", + url: "/api/v1/tools/bulk-rename", + headers: { + authorization: `Bearer ${adminToken}`, + "content-type": contentType, + }, + body, + }); + + expect(res.statusCode).toBe(200); + + const filenames = zipEntryNames(res.rawPayload); + + // 3 files starting at 1, max index is 3 => 1 char pad + expect(filenames).toContain("img-1.png"); + expect(filenames).toContain("img-2.jpg"); + expect(filenames).toContain("img-3.webp"); + }); + + it("renames files using {{original}} placeholder", async () => { + const { body, contentType } = createMultipartPayload([ + { name: "file", filename: "sunset.png", contentType: "image/png", content: PNG }, + { name: "file", filename: "beach.jpg", contentType: "image/jpeg", content: JPG }, + { name: "settings", content: JSON.stringify({ pattern: "backup-{{original}}" }) }, + ]); + + const res = await app.inject({ + method: "POST", + url: "/api/v1/tools/bulk-rename", + headers: { + authorization: `Bearer ${adminToken}`, + "content-type": contentType, + }, + body, + }); + + expect(res.statusCode).toBe(200); + + const filenames = zipEntryNames(res.rawPayload); + expect(filenames).toContain("backup-sunset.png"); + expect(filenames).toContain("backup-beach.jpg"); + }); + + it("respects custom startIndex", async () => { + const { body, contentType } = createMultipartPayload([ + { name: "file", filename: "a.png", contentType: "image/png", content: PNG }, + { name: "file", filename: "b.jpg", contentType: "image/jpeg", content: JPG }, + { name: "settings", content: JSON.stringify({ pattern: "file-{{index}}", startIndex: 10 }) }, + ]); + + const res = await app.inject({ + method: "POST", + url: "/api/v1/tools/bulk-rename", + headers: { + authorization: `Bearer ${adminToken}`, + "content-type": contentType, + }, + body, + }); + + expect(res.statusCode).toBe(200); + + const filenames = zipEntryNames(res.rawPayload); + expect(filenames).toContain("file-10.png"); + expect(filenames).toContain("file-11.jpg"); + }); + + it("preserves file content after rename", async () => { + const { body, contentType } = createMultipartPayload([ + { name: "file", filename: "test.png", contentType: "image/png", content: PNG }, + { name: "settings", content: JSON.stringify({ pattern: "renamed-{{index}}" }) }, + ]); + + const res = await app.inject({ + method: "POST", + url: "/api/v1/tools/bulk-rename", + headers: { + authorization: `Bearer ${adminToken}`, + "content-type": contentType, + }, + body, + }); + + expect(res.statusCode).toBe(200); + + const zip = new AdmZip(res.rawPayload); + const entry = zip.getEntry("renamed-1.png"); + expect(entry).not.toBeNull(); + + const content = entry!.getData(); + expect(content.length).toBe(PNG.length); + expect(content.equals(PNG)).toBe(true); + }); + + // ── Validation ────────────────────────────────────────────────────── + + it("rejects requests with no files", async () => { + const { body, contentType } = createMultipartPayload([ + { name: "settings", content: JSON.stringify({ pattern: "file-{{index}}" }) }, + ]); + + const res = await app.inject({ + method: "POST", + url: "/api/v1/tools/bulk-rename", + headers: { + authorization: `Bearer ${adminToken}`, + "content-type": contentType, + }, + body, + }); + + expect(res.statusCode).toBe(400); + const result = JSON.parse(res.body); + expect(result.error).toMatch(/no files/i); + }); + + it("rejects unauthenticated requests", async () => { + const { body, contentType } = createMultipartPayload([ + { name: "file", filename: "test.png", contentType: "image/png", content: PNG }, + { name: "settings", content: JSON.stringify({}) }, + ]); + + const res = await app.inject({ + method: "POST", + url: "/api/v1/tools/bulk-rename", + headers: { "content-type": contentType }, + body, + }); + + expect(res.statusCode).toBe(401); + }); +}); diff --git a/tests/integration/collage.test.ts b/tests/integration/collage.test.ts new file mode 100644 index 00000000..d41fcb5d --- /dev/null +++ b/tests/integration/collage.test.ts @@ -0,0 +1,403 @@ +/** + * Integration tests for the collage tool (/api/v1/tools/collage). + * + * Collage accepts multiple images (any field name, type === "file") and + * arranges them in a template-based grid layout. It uses a custom route + * rather than createToolRoute. + */ + +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import sharp from "sharp"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { buildTestApp, createMultipartPayload, loginAsAdmin, type TestApp } from "./test-server.js"; + +const FIXTURES = join(__dirname, "..", "fixtures"); +const PNG = readFileSync(join(FIXTURES, "test-200x150.png")); +const JPG = readFileSync(join(FIXTURES, "test-100x100.jpg")); + +let testApp: TestApp; +let app: TestApp["app"]; +let adminToken: string; + +beforeAll(async () => { + testApp = await buildTestApp(); + app = testApp.app; + adminToken = await loginAsAdmin(app); +}, 30_000); + +afterAll(async () => { + await testApp.cleanup(); +}, 10_000); + +describe("Collage", () => { + it("creates a 2-image horizontal collage", async () => { + const { body, contentType } = createMultipartPayload([ + { name: "file1", filename: "a.png", contentType: "image/png", content: PNG }, + { name: "file2", filename: "b.jpg", contentType: "image/jpeg", content: JPG }, + { + name: "settings", + content: JSON.stringify({ templateId: "2-h-equal" }), + }, + ]); + + const res = await app.inject({ + method: "POST", + url: "/api/v1/tools/collage", + headers: { + authorization: `Bearer ${adminToken}`, + "content-type": contentType, + }, + body, + }); + + expect(res.statusCode).toBe(200); + const result = JSON.parse(res.body); + expect(result.downloadUrl).toBeDefined(); + expect(result.processedSize).toBeGreaterThan(0); + }); + + it("creates a 2-image vertical collage", async () => { + const { body, contentType } = createMultipartPayload([ + { name: "file1", filename: "a.png", contentType: "image/png", content: PNG }, + { name: "file2", filename: "b.jpg", contentType: "image/jpeg", content: JPG }, + { + name: "settings", + content: JSON.stringify({ templateId: "2-v-equal" }), + }, + ]); + + const res = await app.inject({ + method: "POST", + url: "/api/v1/tools/collage", + headers: { + authorization: `Bearer ${adminToken}`, + "content-type": contentType, + }, + body, + }); + + expect(res.statusCode).toBe(200); + const result = JSON.parse(res.body); + expect(result.downloadUrl).toBeDefined(); + }); + + it("creates a 4-image grid collage", async () => { + const { body, contentType } = createMultipartPayload([ + { name: "f1", filename: "a.png", contentType: "image/png", content: PNG }, + { name: "f2", filename: "b.jpg", contentType: "image/jpeg", content: JPG }, + { name: "f3", filename: "c.png", contentType: "image/png", content: PNG }, + { name: "f4", filename: "d.jpg", contentType: "image/jpeg", content: JPG }, + { + name: "settings", + content: JSON.stringify({ templateId: "4-grid" }), + }, + ]); + + const res = await app.inject({ + method: "POST", + url: "/api/v1/tools/collage", + headers: { + authorization: `Bearer ${adminToken}`, + "content-type": contentType, + }, + body, + }); + + expect(res.statusCode).toBe(200); + const result = JSON.parse(res.body); + expect(result.downloadUrl).toBeDefined(); + expect(result.processedSize).toBeGreaterThan(0); + }); + + it("handles single image with a 2-image template (fills first cell only)", async () => { + const { body, contentType } = createMultipartPayload([ + { name: "file", filename: "solo.png", contentType: "image/png", content: PNG }, + { + name: "settings", + content: JSON.stringify({ templateId: "2-h-equal" }), + }, + ]); + + const res = await app.inject({ + method: "POST", + url: "/api/v1/tools/collage", + headers: { + authorization: `Bearer ${adminToken}`, + "content-type": contentType, + }, + body, + }); + + expect(res.statusCode).toBe(200); + const result = JSON.parse(res.body); + expect(result.downloadUrl).toBeDefined(); + }); + + it("applies custom gap between cells", async () => { + const { body, contentType } = createMultipartPayload([ + { name: "f1", filename: "a.png", contentType: "image/png", content: PNG }, + { name: "f2", filename: "b.jpg", contentType: "image/jpeg", content: JPG }, + { + name: "settings", + content: JSON.stringify({ templateId: "2-h-equal", gap: 30 }), + }, + ]); + + const res = await app.inject({ + method: "POST", + url: "/api/v1/tools/collage", + headers: { + authorization: `Bearer ${adminToken}`, + "content-type": contentType, + }, + body, + }); + + expect(res.statusCode).toBe(200); + const result = JSON.parse(res.body); + expect(result.processedSize).toBeGreaterThan(0); + }); + + it("uses a specific aspect ratio", async () => { + const { body, contentType } = createMultipartPayload([ + { name: "f1", filename: "a.png", contentType: "image/png", content: PNG }, + { name: "f2", filename: "b.jpg", contentType: "image/jpeg", content: JPG }, + { + name: "settings", + content: JSON.stringify({ templateId: "2-h-equal", aspectRatio: "1:1" }), + }, + ]); + + const res = await app.inject({ + method: "POST", + url: "/api/v1/tools/collage", + headers: { + authorization: `Bearer ${adminToken}`, + "content-type": contentType, + }, + body, + }); + + expect(res.statusCode).toBe(200); + const result = JSON.parse(res.body); + + // Download and verify square aspect ratio + const dlRes = await app.inject({ + method: "GET", + url: result.downloadUrl, + headers: { authorization: `Bearer ${adminToken}` }, + }); + const meta = await sharp(dlRes.rawPayload).metadata(); + expect(meta.width).toBe(meta.height); + }); + + it("outputs in JPEG format when requested", async () => { + const { body, contentType } = createMultipartPayload([ + { name: "f1", filename: "a.png", contentType: "image/png", content: PNG }, + { name: "f2", filename: "b.jpg", contentType: "image/jpeg", content: JPG }, + { + name: "settings", + content: JSON.stringify({ + templateId: "2-h-equal", + outputFormat: "jpeg", + quality: 80, + }), + }, + ]); + + const res = await app.inject({ + method: "POST", + url: "/api/v1/tools/collage", + headers: { + authorization: `Bearer ${adminToken}`, + "content-type": contentType, + }, + body, + }); + + expect(res.statusCode).toBe(200); + const result = JSON.parse(res.body); + + const dlRes = await app.inject({ + method: "GET", + url: result.downloadUrl, + headers: { authorization: `Bearer ${adminToken}` }, + }); + const meta = await sharp(dlRes.rawPayload).metadata(); + expect(meta.format).toBe("jpeg"); + }); + + it("applies corner radius to cells", async () => { + const { body, contentType } = createMultipartPayload([ + { name: "f1", filename: "a.png", contentType: "image/png", content: PNG }, + { name: "f2", filename: "b.jpg", contentType: "image/jpeg", content: JPG }, + { + name: "settings", + content: JSON.stringify({ templateId: "2-h-equal", cornerRadius: 20 }), + }, + ]); + + const res = await app.inject({ + method: "POST", + url: "/api/v1/tools/collage", + headers: { + authorization: `Bearer ${adminToken}`, + "content-type": contentType, + }, + body, + }); + + expect(res.statusCode).toBe(200); + const result = JSON.parse(res.body); + expect(result.processedSize).toBeGreaterThan(0); + }); + + it("uses transparent background", async () => { + const { body, contentType } = createMultipartPayload([ + { name: "f1", filename: "a.png", contentType: "image/png", content: PNG }, + { name: "f2", filename: "b.jpg", contentType: "image/jpeg", content: JPG }, + { + name: "settings", + content: JSON.stringify({ + templateId: "2-h-equal", + backgroundColor: "transparent", + outputFormat: "png", + }), + }, + ]); + + const res = await app.inject({ + method: "POST", + url: "/api/v1/tools/collage", + headers: { + authorization: `Bearer ${adminToken}`, + "content-type": contentType, + }, + body, + }); + + expect(res.statusCode).toBe(200); + const result = JSON.parse(res.body); + + const dlRes = await app.inject({ + method: "GET", + url: result.downloadUrl, + headers: { authorization: `Bearer ${adminToken}` }, + }); + const meta = await sharp(dlRes.rawPayload).metadata(); + expect(meta.channels).toBe(4); // alpha channel for transparency + }); + + // ── Validation ────────────────────────────────────────────────────── + + it("rejects requests with no images", async () => { + const { body, contentType } = createMultipartPayload([ + { + name: "settings", + content: JSON.stringify({ templateId: "2-h-equal" }), + }, + ]); + + const res = await app.inject({ + method: "POST", + url: "/api/v1/tools/collage", + headers: { + authorization: `Bearer ${adminToken}`, + "content-type": contentType, + }, + body, + }); + + expect(res.statusCode).toBe(400); + const result = JSON.parse(res.body); + expect(result.error).toMatch(/no images/i); + }); + + it("rejects unknown template ID", async () => { + const { body, contentType } = createMultipartPayload([ + { name: "file", filename: "a.png", contentType: "image/png", content: PNG }, + { + name: "settings", + content: JSON.stringify({ templateId: "nonexistent-layout" }), + }, + ]); + + const res = await app.inject({ + method: "POST", + url: "/api/v1/tools/collage", + headers: { + authorization: `Bearer ${adminToken}`, + "content-type": contentType, + }, + body, + }); + + expect(res.statusCode).toBe(400); + const result = JSON.parse(res.body); + expect(result.error).toMatch(/unknown template/i); + }); + + it("rejects gap exceeding max (500)", async () => { + const { body, contentType } = createMultipartPayload([ + { name: "file", filename: "a.png", contentType: "image/png", content: PNG }, + { + name: "settings", + content: JSON.stringify({ templateId: "2-h-equal", gap: 600 }), + }, + ]); + + const res = await app.inject({ + method: "POST", + url: "/api/v1/tools/collage", + headers: { + authorization: `Bearer ${adminToken}`, + "content-type": contentType, + }, + body, + }); + + expect(res.statusCode).toBe(400); + }); + + it("rejects invalid output format", async () => { + const { body, contentType } = createMultipartPayload([ + { name: "file", filename: "a.png", contentType: "image/png", content: PNG }, + { + name: "settings", + content: JSON.stringify({ templateId: "2-h-equal", outputFormat: "bmp" }), + }, + ]); + + const res = await app.inject({ + method: "POST", + url: "/api/v1/tools/collage", + headers: { + authorization: `Bearer ${adminToken}`, + "content-type": contentType, + }, + body, + }); + + expect(res.statusCode).toBe(400); + }); + + it("rejects unauthenticated requests", async () => { + const { body, contentType } = createMultipartPayload([ + { name: "file", filename: "a.png", contentType: "image/png", content: PNG }, + { + name: "settings", + content: JSON.stringify({ templateId: "2-h-equal" }), + }, + ]); + + const res = await app.inject({ + method: "POST", + url: "/api/v1/tools/collage", + headers: { "content-type": contentType }, + body, + }); + + expect(res.statusCode).toBe(401); + }); +}); diff --git a/tests/integration/color-adjustments.test.ts b/tests/integration/color-adjustments.test.ts new file mode 100644 index 00000000..c80de754 --- /dev/null +++ b/tests/integration/color-adjustments.test.ts @@ -0,0 +1,319 @@ +/** + * Integration tests for the color-adjustments tool. + * + * Covers brightness, contrast, exposure, saturation, hue, temperature, tint, + * sharpness, channel adjustments, and effects (grayscale, sepia, invert). + * Also tests legacy alias routes (brightness-contrast, saturation, etc.). + */ + +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import sharp from "sharp"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { buildTestApp, createMultipartPayload, loginAsAdmin, type TestApp } from "./test-server.js"; + +const FIXTURES = join(__dirname, "..", "fixtures"); +const PNG = readFileSync(join(FIXTURES, "test-200x150.png")); +const JPG = readFileSync(join(FIXTURES, "test-100x100.jpg")); +const WEBP = readFileSync(join(FIXTURES, "test-50x50.webp")); + +let testApp: TestApp; +let app: TestApp["app"]; +let adminToken: string; + +beforeAll(async () => { + testApp = await buildTestApp(); + app = testApp.app; + adminToken = await loginAsAdmin(app); +}, 30_000); + +afterAll(async () => { + await testApp.cleanup(); +}, 10_000); + +function makePayload( + settings: Record, + buffer: Buffer = PNG, + filename = "test.png", + contentType = "image/png", +) { + return createMultipartPayload([ + { name: "file", filename, contentType, content: buffer }, + { name: "settings", content: JSON.stringify(settings) }, + ]); +} + +async function postTool( + toolId: string, + settings: Record, + buffer?: Buffer, + filename?: string, + ct?: string, +) { + const { body: payload, contentType } = makePayload(settings, buffer, filename, ct); + return app.inject({ + method: "POST", + url: `/api/v1/tools/${toolId}`, + payload, + headers: { + "content-type": contentType, + authorization: `Bearer ${adminToken}`, + }, + }); +} + +// ── Brightness ──────────────────────────────────────────────────── +describe("Brightness", () => { + it("increases brightness", async () => { + const res = await postTool("adjust-colors", { brightness: 50 }); + expect(res.statusCode).toBe(200); + const result = JSON.parse(res.body); + expect(result.downloadUrl).toBeDefined(); + expect(result.processedSize).toBeGreaterThan(0); + }); + + it("decreases brightness", async () => { + const res = await postTool("adjust-colors", { brightness: -50 }); + expect(res.statusCode).toBe(200); + const result = JSON.parse(res.body); + expect(result.downloadUrl).toBeDefined(); + }); +}); + +// ── Contrast ────────────────────────────────────────────────────── +describe("Contrast", () => { + it("increases contrast", async () => { + const res = await postTool("adjust-colors", { contrast: 75 }); + expect(res.statusCode).toBe(200); + const result = JSON.parse(res.body); + expect(result.downloadUrl).toBeDefined(); + }); + + it("decreases contrast", async () => { + const res = await postTool("adjust-colors", { contrast: -40 }); + expect(res.statusCode).toBe(200); + const result = JSON.parse(res.body); + expect(result.downloadUrl).toBeDefined(); + }); +}); + +// ── Multiple adjustments at once ────────────────────────────────── +describe("Multiple adjustments", () => { + it("applies brightness + contrast + saturation together", async () => { + const res = await postTool("adjust-colors", { + brightness: 20, + contrast: -10, + saturation: 30, + }); + expect(res.statusCode).toBe(200); + const result = JSON.parse(res.body); + expect(result.downloadUrl).toBeDefined(); + expect(result.processedSize).toBeGreaterThan(0); + }); + + it("applies exposure + temperature + tint", async () => { + const res = await postTool("adjust-colors", { + exposure: 25, + temperature: 40, + tint: -20, + }); + expect(res.statusCode).toBe(200); + const result = JSON.parse(res.body); + expect(result.downloadUrl).toBeDefined(); + }); + + it("applies hue shift + sharpness + channel adjustments", async () => { + const res = await postTool("adjust-colors", { + hue: 90, + sharpness: 50, + red: 120, + green: 80, + blue: 150, + }); + expect(res.statusCode).toBe(200); + const result = JSON.parse(res.body); + expect(result.downloadUrl).toBeDefined(); + }); +}); + +// ── Extreme values ──────────────────────────────────────────────── +describe("Extreme values", () => { + it("handles max brightness (+100)", async () => { + const res = await postTool("adjust-colors", { brightness: 100 }); + expect(res.statusCode).toBe(200); + }); + + it("handles min brightness (-100)", async () => { + const res = await postTool("adjust-colors", { brightness: -100 }); + expect(res.statusCode).toBe(200); + }); + + it("handles max contrast (+100)", async () => { + const res = await postTool("adjust-colors", { contrast: 100 }); + expect(res.statusCode).toBe(200); + }); + + it("handles all zeroes (no-op)", async () => { + const res = await postTool("adjust-colors", {}); + expect(res.statusCode).toBe(200); + const result = JSON.parse(res.body); + expect(result.processedSize).toBeGreaterThan(0); + }); + + it("handles extreme hue rotation (+180)", async () => { + const res = await postTool("adjust-colors", { hue: 180 }); + expect(res.statusCode).toBe(200); + }); + + it("handles extreme hue rotation (-180)", async () => { + const res = await postTool("adjust-colors", { hue: -180 }); + expect(res.statusCode).toBe(200); + }); + + it("handles max channel values (red=200, green=200, blue=200)", async () => { + const res = await postTool("adjust-colors", { red: 200, green: 200, blue: 200 }); + expect(res.statusCode).toBe(200); + }); + + it("handles min channel values (red=0, green=0, blue=0)", async () => { + const res = await postTool("adjust-colors", { red: 0, green: 0, blue: 0 }); + expect(res.statusCode).toBe(200); + }); +}); + +// ── Effects ─────────────────────────────────────────────────────── +describe("Effects", () => { + it("applies grayscale effect", async () => { + const res = await postTool("adjust-colors", { effect: "grayscale" }); + expect(res.statusCode).toBe(200); + const result = JSON.parse(res.body); + expect(result.downloadUrl).toBeDefined(); + }); + + it("applies sepia effect", async () => { + const res = await postTool("adjust-colors", { effect: "sepia" }); + expect(res.statusCode).toBe(200); + const result = JSON.parse(res.body); + expect(result.downloadUrl).toBeDefined(); + }); + + it("applies invert effect", async () => { + const res = await postTool("adjust-colors", { effect: "invert" }); + expect(res.statusCode).toBe(200); + const result = JSON.parse(res.body); + expect(result.downloadUrl).toBeDefined(); + }); +}); + +// ── Format support ──────────────────────────────────────────────── +describe("Multiple input formats", () => { + it("processes JPEG input", async () => { + const res = await postTool("adjust-colors", { brightness: 30 }, JPG, "test.jpg", "image/jpeg"); + expect(res.statusCode).toBe(200); + const result = JSON.parse(res.body); + expect(result.downloadUrl).toBeDefined(); + }); + + it("processes WebP input", async () => { + const res = await postTool( + "adjust-colors", + { saturation: 50 }, + WEBP, + "test.webp", + "image/webp", + ); + expect(res.statusCode).toBe(200); + const result = JSON.parse(res.body); + expect(result.downloadUrl).toBeDefined(); + }); +}); + +// ── Legacy alias routes ─────────────────────────────────────────── +describe("Legacy alias routes", () => { + it("brightness-contrast alias works", async () => { + const res = await postTool("brightness-contrast", { brightness: 25, contrast: -10 }); + expect(res.statusCode).toBe(200); + const result = JSON.parse(res.body); + expect(result.downloadUrl).toBeDefined(); + }); + + it("saturation alias works", async () => { + const res = await postTool("saturation", { saturation: 50 }); + expect(res.statusCode).toBe(200); + const result = JSON.parse(res.body); + expect(result.downloadUrl).toBeDefined(); + }); + + it("color-channels alias works", async () => { + const res = await postTool("color-channels", { red: 150, green: 50, blue: 100 }); + expect(res.statusCode).toBe(200); + const result = JSON.parse(res.body); + expect(result.downloadUrl).toBeDefined(); + }); + + it("color-effects alias works", async () => { + const res = await postTool("color-effects", { effect: "sepia" }); + expect(res.statusCode).toBe(200); + const result = JSON.parse(res.body); + expect(result.downloadUrl).toBeDefined(); + }); +}); + +// ── Error handling ──────────────────────────────────────────────── +describe("Error handling", () => { + it("returns 400 when no file is provided", async () => { + const { body: payload, contentType } = createMultipartPayload([ + { name: "settings", content: JSON.stringify({ brightness: 50 }) }, + ]); + const res = await app.inject({ + method: "POST", + url: "/api/v1/tools/adjust-colors", + payload, + headers: { + "content-type": contentType, + authorization: `Bearer ${adminToken}`, + }, + }); + expect(res.statusCode).toBe(400); + }); + + it("returns 400 for invalid settings (brightness out of range)", async () => { + const res = await postTool("adjust-colors", { brightness: 500 }); + expect(res.statusCode).toBe(400); + }); + + it("returns 400 for invalid effect value", async () => { + const res = await postTool("adjust-colors", { effect: "neon" }); + expect(res.statusCode).toBe(400); + }); + + it("returns 400 for invalid channel value (negative)", async () => { + const res = await postTool("adjust-colors", { red: -10 }); + expect(res.statusCode).toBe(400); + }); + + it("returns 400 for hue out of range", async () => { + const res = await postTool("adjust-colors", { hue: 360 }); + expect(res.statusCode).toBe(400); + }); +}); + +// ── Download verification ───────────────────────────────────────── +describe("Download verification", () => { + it("can download the adjusted image and it has valid dimensions", async () => { + const res = await postTool("adjust-colors", { brightness: 30, effect: "sepia" }); + expect(res.statusCode).toBe(200); + const result = JSON.parse(res.body); + + const dlRes = await app.inject({ + method: "GET", + url: result.downloadUrl, + headers: { authorization: `Bearer ${adminToken}` }, + }); + expect(dlRes.statusCode).toBe(200); + + const meta = await sharp(dlRes.rawPayload).metadata(); + expect(meta.width).toBe(200); + expect(meta.height).toBe(150); + }); +}); diff --git a/tests/integration/color-palette.test.ts b/tests/integration/color-palette.test.ts new file mode 100644 index 00000000..de180d9f --- /dev/null +++ b/tests/integration/color-palette.test.ts @@ -0,0 +1,175 @@ +/** + * Integration tests for the color-palette tool. + * + * This tool extracts dominant colors from an image and returns JSON + * (not an image). Tests verify response shape, color count, and format handling. + */ + +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import sharp from "sharp"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { buildTestApp, createMultipartPayload, loginAsAdmin, type TestApp } from "./test-server.js"; + +const FIXTURES = join(__dirname, "..", "fixtures"); +const PNG = readFileSync(join(FIXTURES, "test-200x150.png")); +const JPG = readFileSync(join(FIXTURES, "test-100x100.jpg")); +const WEBP = readFileSync(join(FIXTURES, "test-50x50.webp")); + +let testApp: TestApp; +let app: TestApp["app"]; +let adminToken: string; + +beforeAll(async () => { + testApp = await buildTestApp(); + app = testApp.app; + adminToken = await loginAsAdmin(app); +}, 30_000); + +afterAll(async () => { + await testApp.cleanup(); +}, 10_000); + +function makeFilePayload(buffer: Buffer, filename: string, contentType: string) { + return createMultipartPayload([{ name: "file", filename, contentType, content: buffer }]); +} + +// ── Basic extraction ────────────────────────────────────────────── +describe("Color extraction", () => { + it("extracts palette from PNG and returns colors array", async () => { + const { body: payload, contentType } = makeFilePayload(PNG, "test.png", "image/png"); + const res = await app.inject({ + method: "POST", + url: "/api/v1/tools/color-palette", + payload, + headers: { + "content-type": contentType, + authorization: `Bearer ${adminToken}`, + }, + }); + expect(res.statusCode).toBe(200); + const result = JSON.parse(res.body); + expect(result.colors).toBeDefined(); + expect(Array.isArray(result.colors)).toBe(true); + expect(result.colors.length).toBeGreaterThan(0); + expect(result.colors.length).toBeLessThanOrEqual(8); + expect(result.count).toBe(result.colors.length); + expect(result.filename).toBeDefined(); + }); + + it("returns hex color strings in #RRGGBB format", async () => { + const { body: payload, contentType } = makeFilePayload(PNG, "test.png", "image/png"); + const res = await app.inject({ + method: "POST", + url: "/api/v1/tools/color-palette", + payload, + headers: { + "content-type": contentType, + authorization: `Bearer ${adminToken}`, + }, + }); + expect(res.statusCode).toBe(200); + const result = JSON.parse(res.body); + for (const color of result.colors) { + expect(color).toMatch(/^#[0-9a-f]{6}$/); + } + }); +}); + +// ── Solid color image ───────────────────────────────────────────── +describe("Solid color image", () => { + it("returns a single dominant color for a solid red image", async () => { + const redBuffer = await sharp({ + create: { width: 50, height: 50, channels: 3, background: { r: 255, g: 0, b: 0 } }, + }) + .png() + .toBuffer(); + + const { body: payload, contentType } = makeFilePayload(redBuffer, "red.png", "image/png"); + const res = await app.inject({ + method: "POST", + url: "/api/v1/tools/color-palette", + payload, + headers: { + "content-type": contentType, + authorization: `Bearer ${adminToken}`, + }, + }); + expect(res.statusCode).toBe(200); + const result = JSON.parse(res.body); + expect(result.colors.length).toBe(1); + // The quantized red should be close to #f00000 or #ff0000 + expect(result.colors[0]).toMatch(/^#[ef][0f]0000$/); + }); +}); + +// ── Format support ──────────────────────────────────────────────── +describe("Multiple input formats", () => { + it("extracts palette from JPEG", async () => { + const { body: payload, contentType } = makeFilePayload(JPG, "test.jpg", "image/jpeg"); + const res = await app.inject({ + method: "POST", + url: "/api/v1/tools/color-palette", + payload, + headers: { + "content-type": contentType, + authorization: `Bearer ${adminToken}`, + }, + }); + expect(res.statusCode).toBe(200); + const result = JSON.parse(res.body); + expect(result.colors.length).toBeGreaterThan(0); + }); + + it("extracts palette from WebP", async () => { + const { body: payload, contentType } = makeFilePayload(WEBP, "test.webp", "image/webp"); + const res = await app.inject({ + method: "POST", + url: "/api/v1/tools/color-palette", + payload, + headers: { + "content-type": contentType, + authorization: `Bearer ${adminToken}`, + }, + }); + expect(res.statusCode).toBe(200); + const result = JSON.parse(res.body); + expect(result.colors.length).toBeGreaterThan(0); + }); +}); + +// ── Error handling ──────────────────────────────────────────────── +describe("Error handling", () => { + it("returns 400 when no file is provided", async () => { + const { body: payload, contentType } = createMultipartPayload([ + { name: "other", content: "nothing" }, + ]); + const res = await app.inject({ + method: "POST", + url: "/api/v1/tools/color-palette", + payload, + headers: { + "content-type": contentType, + authorization: `Bearer ${adminToken}`, + }, + }); + expect(res.statusCode).toBe(400); + const result = JSON.parse(res.body); + expect(result.error).toBeDefined(); + }); + + it("returns 422 for corrupted image data", async () => { + const badBuffer = Buffer.from("not an image at all"); + const { body: payload, contentType } = makeFilePayload(badBuffer, "bad.png", "image/png"); + const res = await app.inject({ + method: "POST", + url: "/api/v1/tools/color-palette", + payload, + headers: { + "content-type": contentType, + authorization: `Bearer ${adminToken}`, + }, + }); + expect(res.statusCode).toBe(422); + }); +}); diff --git a/tests/integration/compare.test.ts b/tests/integration/compare.test.ts new file mode 100644 index 00000000..daa79255 --- /dev/null +++ b/tests/integration/compare.test.ts @@ -0,0 +1,224 @@ +/** + * Integration tests for the compare tool (/api/v1/tools/compare). + * + * Covers identical-image comparison, different-image comparison, + * similarity score, diff image generation, and input validation. + */ + +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import sharp from "sharp"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { buildTestApp, createMultipartPayload, loginAsAdmin, type TestApp } from "./test-server.js"; + +const FIXTURES = join(__dirname, "..", "fixtures"); +const PNG = readFileSync(join(FIXTURES, "test-200x150.png")); +const JPG = readFileSync(join(FIXTURES, "test-100x100.jpg")); +const WEBP = readFileSync(join(FIXTURES, "test-50x50.webp")); + +let testApp: TestApp; +let app: TestApp["app"]; +let adminToken: string; + +beforeAll(async () => { + testApp = await buildTestApp(); + app = testApp.app; + adminToken = await loginAsAdmin(app); +}, 30_000); + +afterAll(async () => { + await testApp.cleanup(); +}, 10_000); + +describe("Compare", () => { + it("reports 100% similarity when comparing an image with itself", async () => { + const { body, contentType } = createMultipartPayload([ + { name: "file", filename: "a.png", contentType: "image/png", content: PNG }, + { name: "file", filename: "b.png", contentType: "image/png", content: PNG }, + ]); + + const res = await app.inject({ + method: "POST", + url: "/api/v1/tools/compare", + headers: { + authorization: `Bearer ${adminToken}`, + "content-type": contentType, + }, + body, + }); + + expect(res.statusCode).toBe(200); + const result = JSON.parse(res.body); + + expect(result.similarity).toBe(100); + expect(result.jobId).toBeDefined(); + expect(result.downloadUrl).toBeDefined(); + expect(result.dimensions).toBeDefined(); + expect(result.dimensions.width).toBe(200); + expect(result.dimensions.height).toBe(150); + }); + + it("reports less than 100% similarity for different images", async () => { + const { body, contentType } = createMultipartPayload([ + { name: "file", filename: "a.png", contentType: "image/png", content: PNG }, + { name: "file", filename: "b.jpg", contentType: "image/jpeg", content: JPG }, + ]); + + const res = await app.inject({ + method: "POST", + url: "/api/v1/tools/compare", + headers: { + authorization: `Bearer ${adminToken}`, + "content-type": contentType, + }, + body, + }); + + expect(res.statusCode).toBe(200); + const result = JSON.parse(res.body); + + expect(result.similarity).toBeLessThan(100); + expect(result.similarity).toBeGreaterThanOrEqual(0); + expect(result.downloadUrl).toBeDefined(); + }); + + it("generates a downloadable diff image", async () => { + const { body, contentType } = createMultipartPayload([ + { name: "file", filename: "a.png", contentType: "image/png", content: PNG }, + { name: "file", filename: "b.jpg", contentType: "image/jpeg", content: JPG }, + ]); + + const res = await app.inject({ + method: "POST", + url: "/api/v1/tools/compare", + headers: { + authorization: `Bearer ${adminToken}`, + "content-type": contentType, + }, + body, + }); + + expect(res.statusCode).toBe(200); + const result = JSON.parse(res.body); + + // Download the diff image + const dlRes = await app.inject({ + method: "GET", + url: result.downloadUrl, + }); + + expect(dlRes.statusCode).toBe(200); + const meta = await sharp(dlRes.rawPayload).metadata(); + expect(meta.format).toBe("png"); + expect(meta.width).toBe(result.dimensions.width); + expect(meta.height).toBe(result.dimensions.height); + }); + + it("uses the larger dimensions when comparing different-sized images", async () => { + const { body, contentType } = createMultipartPayload([ + { name: "file", filename: "a.png", contentType: "image/png", content: PNG }, + { name: "file", filename: "b.webp", contentType: "image/webp", content: WEBP }, + ]); + + const res = await app.inject({ + method: "POST", + url: "/api/v1/tools/compare", + headers: { + authorization: `Bearer ${adminToken}`, + "content-type": contentType, + }, + body, + }); + + expect(res.statusCode).toBe(200); + const result = JSON.parse(res.body); + + // Max of (200, 50) = 200 wide; Max of (150, 50) = 150 tall + expect(result.dimensions.width).toBe(200); + expect(result.dimensions.height).toBe(150); + }); + + it("returns all expected fields in the response", async () => { + const { body, contentType } = createMultipartPayload([ + { name: "file", filename: "a.png", contentType: "image/png", content: PNG }, + { name: "file", filename: "b.png", contentType: "image/png", content: PNG }, + ]); + + const res = await app.inject({ + method: "POST", + url: "/api/v1/tools/compare", + headers: { + authorization: `Bearer ${adminToken}`, + "content-type": contentType, + }, + body, + }); + + expect(res.statusCode).toBe(200); + const result = JSON.parse(res.body); + + expect(result).toHaveProperty("jobId"); + expect(result).toHaveProperty("similarity"); + expect(result).toHaveProperty("dimensions"); + expect(result).toHaveProperty("downloadUrl"); + expect(result).toHaveProperty("originalSize"); + expect(result).toHaveProperty("processedSize"); + expect(result.processedSize).toBeGreaterThan(0); + }); + + // ── Validation ────────────────────────────────────────────────────── + + it("rejects requests with only one image", async () => { + const { body, contentType } = createMultipartPayload([ + { name: "file", filename: "a.png", contentType: "image/png", content: PNG }, + ]); + + const res = await app.inject({ + method: "POST", + url: "/api/v1/tools/compare", + headers: { + authorization: `Bearer ${adminToken}`, + "content-type": contentType, + }, + body, + }); + + expect(res.statusCode).toBe(400); + const result = JSON.parse(res.body); + expect(result.error).toMatch(/two image/i); + }); + + it("rejects requests with no images", async () => { + const { body, contentType } = createMultipartPayload([{ name: "dummy", content: "nothing" }]); + + const res = await app.inject({ + method: "POST", + url: "/api/v1/tools/compare", + headers: { + authorization: `Bearer ${adminToken}`, + "content-type": contentType, + }, + body, + }); + + expect(res.statusCode).toBe(400); + const result = JSON.parse(res.body); + expect(result.error).toMatch(/two image/i); + }); + + it("rejects unauthenticated requests", async () => { + const { body, contentType } = createMultipartPayload([ + { name: "file", filename: "a.png", contentType: "image/png", content: PNG }, + { name: "file", filename: "b.png", contentType: "image/png", content: PNG }, + ]); + + const res = await app.inject({ + method: "POST", + url: "/api/v1/tools/compare", + headers: { "content-type": contentType }, + body, + }); + + expect(res.statusCode).toBe(401); + }); +}); diff --git a/tests/integration/compose.test.ts b/tests/integration/compose.test.ts new file mode 100644 index 00000000..ad3ad3fe --- /dev/null +++ b/tests/integration/compose.test.ts @@ -0,0 +1,296 @@ +/** + * Integration tests for the compose tool (/api/v1/tools/compose). + * + * Compose overlays one image on top of another with position, opacity, + * and blend mode controls. It uses field names "file" for the base image + * and "overlay" for the overlay image. + */ + +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import sharp from "sharp"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { buildTestApp, createMultipartPayload, loginAsAdmin, type TestApp } from "./test-server.js"; + +const FIXTURES = join(__dirname, "..", "fixtures"); +const PNG = readFileSync(join(FIXTURES, "test-200x150.png")); +const JPG = readFileSync(join(FIXTURES, "test-100x100.jpg")); + +let testApp: TestApp; +let app: TestApp["app"]; +let adminToken: string; + +beforeAll(async () => { + testApp = await buildTestApp(); + app = testApp.app; + adminToken = await loginAsAdmin(app); +}, 30_000); + +afterAll(async () => { + await testApp.cleanup(); +}, 10_000); + +describe("Compose", () => { + it("overlays a small image onto a base image at default position", async () => { + const { body, contentType } = createMultipartPayload([ + { name: "file", filename: "base.png", contentType: "image/png", content: PNG }, + { name: "overlay", filename: "overlay.jpg", contentType: "image/jpeg", content: JPG }, + { name: "settings", content: JSON.stringify({}) }, + ]); + + const res = await app.inject({ + method: "POST", + url: "/api/v1/tools/compose", + headers: { + authorization: `Bearer ${adminToken}`, + "content-type": contentType, + }, + body, + }); + + expect(res.statusCode).toBe(200); + const result = JSON.parse(res.body); + expect(result.downloadUrl).toBeDefined(); + expect(result.processedSize).toBeGreaterThan(0); + + // Output should preserve base image dimensions + const dlRes = await app.inject({ + method: "GET", + url: result.downloadUrl, + headers: { authorization: `Bearer ${adminToken}` }, + }); + const meta = await sharp(dlRes.rawPayload).metadata(); + expect(meta.width).toBe(200); + expect(meta.height).toBe(150); + }); + + it("positions overlay at a specific x,y offset", async () => { + const { body, contentType } = createMultipartPayload([ + { name: "file", filename: "base.png", contentType: "image/png", content: PNG }, + { name: "overlay", filename: "overlay.jpg", contentType: "image/jpeg", content: JPG }, + { name: "settings", content: JSON.stringify({ x: 50, y: 25 }) }, + ]); + + const res = await app.inject({ + method: "POST", + url: "/api/v1/tools/compose", + headers: { + authorization: `Bearer ${adminToken}`, + "content-type": contentType, + }, + body, + }); + + expect(res.statusCode).toBe(200); + const result = JSON.parse(res.body); + expect(result.downloadUrl).toBeDefined(); + + // Dimensions should still match the base image + const dlRes = await app.inject({ + method: "GET", + url: result.downloadUrl, + headers: { authorization: `Bearer ${adminToken}` }, + }); + const meta = await sharp(dlRes.rawPayload).metadata(); + expect(meta.width).toBe(200); + expect(meta.height).toBe(150); + }); + + it("applies opacity to the overlay", async () => { + const { body, contentType } = createMultipartPayload([ + { name: "file", filename: "base.png", contentType: "image/png", content: PNG }, + { name: "overlay", filename: "overlay.jpg", contentType: "image/jpeg", content: JPG }, + { name: "settings", content: JSON.stringify({ opacity: 50 }) }, + ]); + + const res = await app.inject({ + method: "POST", + url: "/api/v1/tools/compose", + headers: { + authorization: `Bearer ${adminToken}`, + "content-type": contentType, + }, + body, + }); + + expect(res.statusCode).toBe(200); + const result = JSON.parse(res.body); + expect(result.processedSize).toBeGreaterThan(0); + }); + + it("uses multiply blend mode", async () => { + const { body, contentType } = createMultipartPayload([ + { name: "file", filename: "base.png", contentType: "image/png", content: PNG }, + { name: "overlay", filename: "overlay.jpg", contentType: "image/jpeg", content: JPG }, + { name: "settings", content: JSON.stringify({ blendMode: "multiply" }) }, + ]); + + const res = await app.inject({ + method: "POST", + url: "/api/v1/tools/compose", + headers: { + authorization: `Bearer ${adminToken}`, + "content-type": contentType, + }, + body, + }); + + expect(res.statusCode).toBe(200); + const result = JSON.parse(res.body); + expect(result.downloadUrl).toBeDefined(); + }); + + it("uses screen blend mode", async () => { + const { body, contentType } = createMultipartPayload([ + { name: "file", filename: "base.png", contentType: "image/png", content: PNG }, + { name: "overlay", filename: "overlay.jpg", contentType: "image/jpeg", content: JPG }, + { name: "settings", content: JSON.stringify({ blendMode: "screen" }) }, + ]); + + const res = await app.inject({ + method: "POST", + url: "/api/v1/tools/compose", + headers: { + authorization: `Bearer ${adminToken}`, + "content-type": contentType, + }, + body, + }); + + expect(res.statusCode).toBe(200); + }); + + it("combines position, opacity, and blend mode", async () => { + const { body, contentType } = createMultipartPayload([ + { name: "file", filename: "base.png", contentType: "image/png", content: PNG }, + { name: "overlay", filename: "overlay.jpg", contentType: "image/jpeg", content: JPG }, + { + name: "settings", + content: JSON.stringify({ + x: 10, + y: 10, + opacity: 75, + blendMode: "overlay", + }), + }, + ]); + + const res = await app.inject({ + method: "POST", + url: "/api/v1/tools/compose", + headers: { + authorization: `Bearer ${adminToken}`, + "content-type": contentType, + }, + body, + }); + + expect(res.statusCode).toBe(200); + const result = JSON.parse(res.body); + expect(result.downloadUrl).toBeDefined(); + expect(result.processedSize).toBeGreaterThan(0); + }); + + // ── Validation ────────────────────────────────────────────────────── + + it("rejects requests without a base image", async () => { + const { body, contentType } = createMultipartPayload([ + { name: "overlay", filename: "overlay.jpg", contentType: "image/jpeg", content: JPG }, + { name: "settings", content: JSON.stringify({}) }, + ]); + + const res = await app.inject({ + method: "POST", + url: "/api/v1/tools/compose", + headers: { + authorization: `Bearer ${adminToken}`, + "content-type": contentType, + }, + body, + }); + + expect(res.statusCode).toBe(400); + const result = JSON.parse(res.body); + expect(result.error).toMatch(/no base image/i); + }); + + it("rejects requests without an overlay image", async () => { + const { body, contentType } = createMultipartPayload([ + { name: "file", filename: "base.png", contentType: "image/png", content: PNG }, + { name: "settings", content: JSON.stringify({}) }, + ]); + + const res = await app.inject({ + method: "POST", + url: "/api/v1/tools/compose", + headers: { + authorization: `Bearer ${adminToken}`, + "content-type": contentType, + }, + body, + }); + + expect(res.statusCode).toBe(400); + const result = JSON.parse(res.body); + expect(result.error).toMatch(/no overlay image/i); + }); + + it("rejects invalid blend mode", async () => { + const { body, contentType } = createMultipartPayload([ + { name: "file", filename: "base.png", contentType: "image/png", content: PNG }, + { name: "overlay", filename: "overlay.jpg", contentType: "image/jpeg", content: JPG }, + { name: "settings", content: JSON.stringify({ blendMode: "bogus" }) }, + ]); + + const res = await app.inject({ + method: "POST", + url: "/api/v1/tools/compose", + headers: { + authorization: `Bearer ${adminToken}`, + "content-type": contentType, + }, + body, + }); + + expect(res.statusCode).toBe(400); + const result = JSON.parse(res.body); + expect(result.error).toMatch(/invalid settings/i); + }); + + it("rejects opacity out of range", async () => { + const { body, contentType } = createMultipartPayload([ + { name: "file", filename: "base.png", contentType: "image/png", content: PNG }, + { name: "overlay", filename: "overlay.jpg", contentType: "image/jpeg", content: JPG }, + { name: "settings", content: JSON.stringify({ opacity: 150 }) }, + ]); + + const res = await app.inject({ + method: "POST", + url: "/api/v1/tools/compose", + headers: { + authorization: `Bearer ${adminToken}`, + "content-type": contentType, + }, + body, + }); + + expect(res.statusCode).toBe(400); + }); + + it("rejects unauthenticated requests", async () => { + const { body, contentType } = createMultipartPayload([ + { name: "file", filename: "base.png", contentType: "image/png", content: PNG }, + { name: "overlay", filename: "overlay.jpg", contentType: "image/jpeg", content: JPG }, + { name: "settings", content: JSON.stringify({}) }, + ]); + + const res = await app.inject({ + method: "POST", + url: "/api/v1/tools/compose", + headers: { "content-type": contentType }, + body, + }); + + expect(res.statusCode).toBe(401); + }); +}); diff --git a/tests/integration/compress.test.ts b/tests/integration/compress.test.ts new file mode 100644 index 00000000..e618de1f --- /dev/null +++ b/tests/integration/compress.test.ts @@ -0,0 +1,208 @@ +/** + * Integration tests for the compress tool. + * + * Tests quality-based and target-size compression modes. Verifies that + * output is actually smaller than input, and that format is preserved. + */ + +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import sharp from "sharp"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { buildTestApp, createMultipartPayload, loginAsAdmin, type TestApp } from "./test-server.js"; + +const FIXTURES = join(__dirname, "..", "fixtures"); +const PNG = readFileSync(join(FIXTURES, "test-200x150.png")); +const JPG = readFileSync(join(FIXTURES, "test-100x100.jpg")); +const WEBP = readFileSync(join(FIXTURES, "test-50x50.webp")); + +let testApp: TestApp; +let app: TestApp["app"]; +let adminToken: string; +let largeJpg: Buffer; + +beforeAll(async () => { + testApp = await buildTestApp(); + app = testApp.app; + adminToken = await loginAsAdmin(app); + + // Create a larger JPEG at max quality for compression tests + largeJpg = await sharp(PNG).jpeg({ quality: 100 }).toBuffer(); +}, 30_000); + +afterAll(async () => { + await testApp.cleanup(); +}, 10_000); + +function makePayload( + settings: Record, + buffer: Buffer = largeJpg, + filename = "test.jpg", + contentType = "image/jpeg", +) { + return createMultipartPayload([ + { name: "file", filename, contentType, content: buffer }, + { name: "settings", content: JSON.stringify(settings) }, + ]); +} + +async function postTool( + settings: Record, + buffer?: Buffer, + filename?: string, + ct?: string, +) { + const { body: payload, contentType } = makePayload(settings, buffer ?? largeJpg, filename, ct); + return app.inject({ + method: "POST", + url: "/api/v1/tools/compress", + payload, + headers: { + "content-type": contentType, + authorization: `Bearer ${adminToken}`, + }, + }); +} + +// ── Quality mode ────────────────────────────────────────────────── +describe("Quality mode compression", () => { + it("compresses JPEG at quality 50", async () => { + const res = await postTool({ mode: "quality", quality: 50 }); + expect(res.statusCode).toBe(200); + const result = JSON.parse(res.body); + expect(result.downloadUrl).toBeDefined(); + expect(result.processedSize).toBeGreaterThan(0); + expect(result.processedSize).toBeLessThan(result.originalSize); + }); + + it("compresses JPEG at quality 10 (very low)", async () => { + const res = await postTool({ mode: "quality", quality: 10 }); + expect(res.statusCode).toBe(200); + const result = JSON.parse(res.body); + expect(result.processedSize).toBeLessThan(result.originalSize); + }); + + it("uses default quality (80) when quality not specified", async () => { + const res = await postTool({ mode: "quality" }); + expect(res.statusCode).toBe(200); + const result = JSON.parse(res.body); + expect(result.downloadUrl).toBeDefined(); + expect(result.processedSize).toBeGreaterThan(0); + }); + + it("lower quality produces smaller output", async () => { + const res50 = await postTool({ mode: "quality", quality: 50 }); + const res10 = await postTool({ mode: "quality", quality: 10 }); + expect(res50.statusCode).toBe(200); + expect(res10.statusCode).toBe(200); + const result50 = JSON.parse(res50.body); + const result10 = JSON.parse(res10.body); + expect(result10.processedSize).toBeLessThan(result50.processedSize); + }); +}); + +// ── Target size mode ────────────────────────────────────────────── +describe("Target size mode", () => { + it("compresses to a target file size", async () => { + const res = await postTool({ mode: "targetSize", targetSizeKb: 5 }); + expect(res.statusCode).toBe(200); + const result = JSON.parse(res.body); + expect(result.downloadUrl).toBeDefined(); + expect(result.processedSize).toBeGreaterThan(0); + }); +}); + +// ── Format preservation ─────────────────────────────────────────── +describe("Format preservation", () => { + it("preserves PNG format", async () => { + const res = await postTool({ mode: "quality", quality: 50 }, PNG, "test.png", "image/png"); + expect(res.statusCode).toBe(200); + const result = JSON.parse(res.body); + + const dlRes = await app.inject({ + method: "GET", + url: result.downloadUrl, + headers: { authorization: `Bearer ${adminToken}` }, + }); + const meta = await sharp(dlRes.rawPayload).metadata(); + expect(meta.format).toBe("png"); + }); + + it("preserves JPEG format", async () => { + const res = await postTool({ mode: "quality", quality: 50 }); + expect(res.statusCode).toBe(200); + const result = JSON.parse(res.body); + + const dlRes = await app.inject({ + method: "GET", + url: result.downloadUrl, + headers: { authorization: `Bearer ${adminToken}` }, + }); + const meta = await sharp(dlRes.rawPayload).metadata(); + expect(meta.format).toBe("jpeg"); + }); + + it("preserves image dimensions after compression", async () => { + const res = await postTool({ mode: "quality", quality: 30 }); + expect(res.statusCode).toBe(200); + const result = JSON.parse(res.body); + + const dlRes = await app.inject({ + method: "GET", + url: result.downloadUrl, + headers: { authorization: `Bearer ${adminToken}` }, + }); + const meta = await sharp(dlRes.rawPayload).metadata(); + expect(meta.width).toBe(200); + expect(meta.height).toBe(150); + }); +}); + +// ── Multiple input formats ──────────────────────────────────────── +describe("Multiple input formats", () => { + it("compresses WebP input", async () => { + const res = await postTool({ mode: "quality", quality: 50 }, WEBP, "test.webp", "image/webp"); + expect(res.statusCode).toBe(200); + const result = JSON.parse(res.body); + expect(result.downloadUrl).toBeDefined(); + }); +}); + +// ── Error handling ──────────────────────────────────────────────── +describe("Error handling", () => { + it("returns 400 when no file is provided", async () => { + const { body: payload, contentType } = createMultipartPayload([ + { name: "settings", content: JSON.stringify({ mode: "quality", quality: 50 }) }, + ]); + const res = await app.inject({ + method: "POST", + url: "/api/v1/tools/compress", + payload, + headers: { + "content-type": contentType, + authorization: `Bearer ${adminToken}`, + }, + }); + expect(res.statusCode).toBe(400); + }); + + it("returns 400 for quality out of range (0)", async () => { + const res = await postTool({ mode: "quality", quality: 0 }); + expect(res.statusCode).toBe(400); + }); + + it("returns 400 for quality out of range (101)", async () => { + const res = await postTool({ mode: "quality", quality: 101 }); + expect(res.statusCode).toBe(400); + }); + + it("returns 400 for invalid mode", async () => { + const res = await postTool({ mode: "supercompress" }); + expect(res.statusCode).toBe(400); + }); + + it("returns 400 for negative targetSizeKb", async () => { + const res = await postTool({ mode: "targetSize", targetSizeKb: -10 }); + expect(res.statusCode).toBe(400); + }); +}); diff --git a/tests/integration/concurrent.test.ts b/tests/integration/concurrent.test.ts new file mode 100644 index 00000000..549f7561 --- /dev/null +++ b/tests/integration/concurrent.test.ts @@ -0,0 +1,177 @@ +/** + * Concurrent request integration tests for the ashim image API. + * + * Tests that the server handles parallel and rapid sequential requests + * correctly without data corruption, crashes, or race conditions. + */ + +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { buildTestApp, createMultipartPayload, loginAsAdmin, type TestApp } from "./test-server.js"; + +// --------------------------------------------------------------------------- +// Fixtures +// --------------------------------------------------------------------------- +const FIXTURES = join(__dirname, "..", "fixtures"); +const PNG_200x150 = readFileSync(join(FIXTURES, "test-200x150.png")); +const JPG_100x100 = readFileSync(join(FIXTURES, "test-100x100.jpg")); + +// --------------------------------------------------------------------------- +// Shared state +// --------------------------------------------------------------------------- +let testApp: TestApp; +let app: TestApp["app"]; +let adminToken: string; + +beforeAll(async () => { + testApp = await buildTestApp(); + app = testApp.app; + adminToken = await loginAsAdmin(app); +}, 30_000); + +afterAll(async () => { + await testApp.cleanup(); +}, 10_000); + +/** Helper to build an inject config for a tool request. */ +function buildToolRequest( + toolId: string, + image: Buffer, + filename: string, + settings: Record, +) { + const { body, contentType } = createMultipartPayload([ + { name: "file", filename, content: image, contentType: "image/png" }, + { name: "settings", content: JSON.stringify(settings) }, + ]); + return { + method: "POST" as const, + url: `/api/v1/tools/${toolId}`, + headers: { "content-type": contentType, authorization: `Bearer ${adminToken}` }, + body, + }; +} + +// ═══════════════════════════════════════════════════════════════════════════ +// PARALLEL UPLOADS TO SAME TOOL +// ═══════════════════════════════════════════════════════════════════════════ +describe("Parallel uploads to the same tool", () => { + it("handles 5 simultaneous resize requests", async () => { + const results = await Promise.all( + Array.from({ length: 5 }, (_, i) => + app.inject( + buildToolRequest("resize", PNG_200x150, `parallel-${i}.png`, { width: 50 + i * 10 }), + ), + ), + ); + + for (const res of results) { + expect(res.statusCode).toBe(200); + const json = JSON.parse(res.body); + expect(json.jobId).toBeDefined(); + expect(json.downloadUrl).toBeDefined(); + } + + // Each request should get a unique jobId + const jobIds = results.map((r) => JSON.parse(r.body).jobId); + const uniqueJobIds = new Set(jobIds); + expect(uniqueJobIds.size).toBe(5); + }, 30_000); +}); + +// ═══════════════════════════════════════════════════════════════════════════ +// PARALLEL UPLOADS TO DIFFERENT TOOLS +// ═══════════════════════════════════════════════════════════════════════════ +describe("Parallel uploads to different tools", () => { + it("handles resize + rotate + compress simultaneously", async () => { + const [resizeRes, rotateRes, compressRes] = await Promise.all([ + app.inject(buildToolRequest("resize", PNG_200x150, "resize.png", { width: 100 })), + app.inject(buildToolRequest("rotate", PNG_200x150, "rotate.png", { angle: 90 })), + app.inject(buildToolRequest("compress", PNG_200x150, "compress.png", { quality: 50 })), + ]); + + expect(resizeRes.statusCode).toBe(200); + expect(rotateRes.statusCode).toBe(200); + expect(compressRes.statusCode).toBe(200); + + // Each result should have a distinct jobId + const ids = [resizeRes, rotateRes, compressRes].map((r) => JSON.parse(r.body).jobId); + expect(new Set(ids).size).toBe(3); + }, 30_000); +}); + +// ═══════════════════════════════════════════════════════════════════════════ +// RAPID SEQUENTIAL REQUESTS +// ═══════════════════════════════════════════════════════════════════════════ +describe("Rapid sequential requests", () => { + it("handles 10 requests in quick succession", async () => { + const results = []; + for (let i = 0; i < 10; i++) { + const res = await app.inject( + buildToolRequest("resize", PNG_200x150, `rapid-${i}.png`, { width: 80 }), + ); + results.push(res); + } + + for (const res of results) { + expect(res.statusCode).toBe(200); + } + + // All should produce unique job IDs + const jobIds = results.map((r) => JSON.parse(r.body).jobId); + expect(new Set(jobIds).size).toBe(10); + }, 30_000); +}); + +// ═══════════════════════════════════════════════════════════════════════════ +// INTERLEAVED UPLOADS +// ═══════════════════════════════════════════════════════════════════════════ +describe("Interleaved uploads", () => { + it("processes two resize requests started nearly simultaneously", async () => { + // Start both at the same time — both should complete independently + const [first, second] = await Promise.all([ + app.inject(buildToolRequest("resize", PNG_200x150, "interleave-a.png", { width: 120 })), + app.inject(buildToolRequest("resize", JPG_100x100, "interleave-b.jpg", { width: 60 })), + ]); + + expect(first.statusCode).toBe(200); + expect(second.statusCode).toBe(200); + + const firstBody = JSON.parse(first.body); + const secondBody = JSON.parse(second.body); + + // Each should get its own job ID and download URL + expect(firstBody.jobId).not.toBe(secondBody.jobId); + expect(firstBody.downloadUrl).not.toBe(secondBody.downloadUrl); + }); +}); + +// ═══════════════════════════════════════════════════════════════════════════ +// CONCURRENT MIX OF VALID AND INVALID REQUESTS +// ═══════════════════════════════════════════════════════════════════════════ +describe("Concurrent mix of valid and invalid requests", () => { + it("handles a mix of valid and invalid requests without cross-contamination", async () => { + const corruptedJpeg = Buffer.concat([ + Buffer.from([0xff, 0xd8, 0xff, 0xe0]), + Buffer.from("garbage data that is not a real image"), + ]); + + const [validRes, invalidRes, anotherValidRes] = await Promise.all([ + app.inject(buildToolRequest("resize", PNG_200x150, "valid.png", { width: 100 })), + app.inject(buildToolRequest("resize", corruptedJpeg, "invalid.jpg", { width: 100 })), + app.inject(buildToolRequest("rotate", PNG_200x150, "also-valid.png", { angle: 45 })), + ]); + + // Valid requests should succeed + expect(validRes.statusCode).toBe(200); + expect(anotherValidRes.statusCode).toBe(200); + + // Invalid request should fail gracefully + expect([400, 422]).toContain(invalidRes.statusCode); + + // The invalid request should not affect the valid ones + expect(JSON.parse(validRes.body).jobId).toBeDefined(); + expect(JSON.parse(anotherValidRes.body).jobId).toBeDefined(); + }); +}); diff --git a/tests/integration/edge-cases.test.ts b/tests/integration/edge-cases.test.ts new file mode 100644 index 00000000..7dcdffbf --- /dev/null +++ b/tests/integration/edge-cases.test.ts @@ -0,0 +1,320 @@ +/** + * Edge-case integration tests for the ashim image API. + * + * Tests unusual but valid inputs: empty files, tiny images, wrong extensions, + * extreme filenames, missing/empty settings, and unknown fields. + */ + +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import sharp from "sharp"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { buildTestApp, createMultipartPayload, loginAsAdmin, type TestApp } from "./test-server.js"; + +// --------------------------------------------------------------------------- +// Fixtures +// --------------------------------------------------------------------------- +const FIXTURES = join(__dirname, "..", "fixtures"); +const PNG_200x150 = readFileSync(join(FIXTURES, "test-200x150.png")); +const PNG_1x1 = readFileSync(join(FIXTURES, "test-1x1.png")); + +// --------------------------------------------------------------------------- +// Shared state +// --------------------------------------------------------------------------- +let testApp: TestApp; +let app: TestApp["app"]; +let adminToken: string; + +beforeAll(async () => { + testApp = await buildTestApp(); + app = testApp.app; + adminToken = await loginAsAdmin(app); +}, 30_000); + +afterAll(async () => { + await testApp.cleanup(); +}, 10_000); + +// ═══════════════════════════════════════════════════════════════════════════ +// ZERO-BYTE AND EMPTY FILES +// ═══════════════════════════════════════════════════════════════════════════ +describe("Zero-byte / empty file uploads", () => { + it("rejects a zero-byte file with 400", async () => { + const { body, contentType } = createMultipartPayload([ + { name: "file", filename: "empty.png", content: Buffer.alloc(0), contentType: "image/png" }, + { name: "settings", content: JSON.stringify({ width: 100 }) }, + ]); + + const res = await app.inject({ + method: "POST", + url: "/api/v1/tools/resize", + headers: { "content-type": contentType, authorization: `Bearer ${adminToken}` }, + body, + }); + + expect(res.statusCode).toBe(400); + const json = JSON.parse(res.body); + expect(json.error).toBeDefined(); + }); + + it("rejects a null-byte-only buffer with 400", async () => { + const nullBuffer = Buffer.alloc(1024); // all zeroes + const { body, contentType } = createMultipartPayload([ + { name: "file", filename: "nulls.png", content: nullBuffer, contentType: "image/png" }, + { name: "settings", content: JSON.stringify({ width: 100 }) }, + ]); + + const res = await app.inject({ + method: "POST", + url: "/api/v1/tools/resize", + headers: { "content-type": contentType, authorization: `Bearer ${adminToken}` }, + body, + }); + + expect(res.statusCode).toBe(400); + }); +}); + +// ═══════════════════════════════════════════════════════════════════════════ +// 1x1 PIXEL IMAGE — MINIMUM DIMENSION +// ═══════════════════════════════════════════════════════════════════════════ +describe("1x1 pixel image handling", () => { + it("resizes a 1x1 image without crashing", async () => { + const { body, contentType } = createMultipartPayload([ + { name: "file", filename: "tiny.png", content: PNG_1x1, contentType: "image/png" }, + { name: "settings", content: JSON.stringify({ width: 50, height: 50 }) }, + ]); + + const res = await app.inject({ + method: "POST", + url: "/api/v1/tools/resize", + headers: { "content-type": contentType, authorization: `Bearer ${adminToken}` }, + body, + }); + + expect(res.statusCode).toBe(200); + }); + + it("crops a 1x1 image gracefully", async () => { + const { body, contentType } = createMultipartPayload([ + { name: "file", filename: "tiny.png", content: PNG_1x1, contentType: "image/png" }, + { name: "settings", content: JSON.stringify({ left: 0, top: 0, width: 1, height: 1 }) }, + ]); + + const res = await app.inject({ + method: "POST", + url: "/api/v1/tools/crop", + headers: { "content-type": contentType, authorization: `Bearer ${adminToken}` }, + body, + }); + + // Should succeed or fail gracefully (not crash) + expect([200, 422]).toContain(res.statusCode); + }); + + it("rotates a 1x1 image gracefully", async () => { + const { body, contentType } = createMultipartPayload([ + { name: "file", filename: "tiny.png", content: PNG_1x1, contentType: "image/png" }, + { name: "settings", content: JSON.stringify({ angle: 90 }) }, + ]); + + const res = await app.inject({ + method: "POST", + url: "/api/v1/tools/rotate", + headers: { "content-type": contentType, authorization: `Bearer ${adminToken}` }, + body, + }); + + expect(res.statusCode).toBe(200); + }); +}); + +// ═══════════════════════════════════════════════════════════════════════════ +// WRONG EXTENSION — FORMAT DETECTION +// ═══════════════════════════════════════════════════════════════════════════ +describe("Wrong file extension (magic byte detection)", () => { + it("detects actual PNG format when filename says .jpg", async () => { + const { body, contentType } = createMultipartPayload([ + { + name: "file", + filename: "actually-a-png.jpg", + content: PNG_200x150, + contentType: "image/jpeg", + }, + { name: "settings", content: JSON.stringify({ width: 100 }) }, + ]); + + const res = await app.inject({ + method: "POST", + url: "/api/v1/tools/resize", + headers: { "content-type": contentType, authorization: `Bearer ${adminToken}` }, + body, + }); + + // Should detect PNG via magic bytes and process successfully + expect(res.statusCode).toBe(200); + }); +}); + +// ═══════════════════════════════════════════════════════════════════════════ +// EXTREME FILENAMES +// ═══════════════════════════════════════════════════════════════════════════ +describe("Extreme filenames", () => { + it("handles a 500-character filename without crashing", async () => { + const longName = "a".repeat(490) + ".png"; + const { body, contentType } = createMultipartPayload([ + { name: "file", filename: longName, content: PNG_200x150, contentType: "image/png" }, + { name: "settings", content: JSON.stringify({ width: 100 }) }, + ]); + + const res = await app.inject({ + method: "POST", + url: "/api/v1/tools/resize", + headers: { "content-type": contentType, authorization: `Bearer ${adminToken}` }, + body, + }); + + expect(res.statusCode).toBe(200); + }); + + it("handles a Unicode filename without crashing", async () => { + const { body, contentType } = createMultipartPayload([ + { name: "file", filename: "画像テスト.png", content: PNG_200x150, contentType: "image/png" }, + { name: "settings", content: JSON.stringify({ width: 100 }) }, + ]); + + const res = await app.inject({ + method: "POST", + url: "/api/v1/tools/resize", + headers: { "content-type": contentType, authorization: `Bearer ${adminToken}` }, + body, + }); + + expect(res.statusCode).toBe(200); + }); + + it("handles special characters in filename", async () => { + const { body, contentType } = createMultipartPayload([ + { + name: "file", + filename: "test file (1) [copy].png", + content: PNG_200x150, + contentType: "image/png", + }, + { name: "settings", content: JSON.stringify({ width: 100 }) }, + ]); + + const res = await app.inject({ + method: "POST", + url: "/api/v1/tools/resize", + headers: { "content-type": contentType, authorization: `Bearer ${adminToken}` }, + body, + }); + + expect(res.statusCode).toBe(200); + }); + + it("sanitizes path traversal in filename", async () => { + const { body, contentType } = createMultipartPayload([ + { + name: "file", + filename: "../../../etc/passwd.png", + content: PNG_200x150, + contentType: "image/png", + }, + { name: "settings", content: JSON.stringify({ width: 100 }) }, + ]); + + const res = await app.inject({ + method: "POST", + url: "/api/v1/tools/resize", + headers: { "content-type": contentType, authorization: `Bearer ${adminToken}` }, + body, + }); + + // Should succeed — filename gets sanitized by sanitizeFilename() + expect(res.statusCode).toBe(200); + const json = JSON.parse(res.body); + // Download URL should not contain path traversal + expect(json.downloadUrl).not.toContain(".."); + }); +}); + +// ═══════════════════════════════════════════════════════════════════════════ +// SETTINGS EDGE CASES +// ═══════════════════════════════════════════════════════════════════════════ +describe("Settings field edge cases", () => { + it("uses defaults when no settings field is provided", async () => { + // Resize without settings — should use Zod defaults or return 400 + const { body, contentType } = createMultipartPayload([ + { name: "file", filename: "test.png", content: PNG_200x150, contentType: "image/png" }, + ]); + + const res = await app.inject({ + method: "POST", + url: "/api/v1/tools/resize", + headers: { "content-type": contentType, authorization: `Bearer ${adminToken}` }, + body, + }); + + // Resize with no width/height/percentage passes Zod validation (all fields + // are optional with defaults), but Sharp may fail at processing time (422). + // Either way, should not crash. + expect([200, 400, 422]).toContain(res.statusCode); + }); + + it("uses defaults when settings is empty JSON object", async () => { + const { body, contentType } = createMultipartPayload([ + { name: "file", filename: "test.png", content: PNG_200x150, contentType: "image/png" }, + { name: "settings", content: "{}" }, + ]); + + const res = await app.inject({ + method: "POST", + url: "/api/v1/tools/resize", + headers: { "content-type": contentType, authorization: `Bearer ${adminToken}` }, + body, + }); + + // Empty settings should use Zod defaults — resize with no dimensions + // may still succeed (no-op) or fail gracefully + expect([200, 400, 422]).toContain(res.statusCode); + }); + + it("handles null values in settings gracefully", async () => { + const { body, contentType } = createMultipartPayload([ + { name: "file", filename: "test.png", content: PNG_200x150, contentType: "image/png" }, + { name: "settings", content: JSON.stringify({ width: null, height: null }) }, + ]); + + const res = await app.inject({ + method: "POST", + url: "/api/v1/tools/resize", + headers: { "content-type": contentType, authorization: `Bearer ${adminToken}` }, + body, + }); + + // Zod should either strip nulls or reject them — should not crash + expect([200, 400, 422]).toContain(res.statusCode); + }); + + it("strips or rejects extra unknown fields in settings", async () => { + const { body, contentType } = createMultipartPayload([ + { name: "file", filename: "test.png", content: PNG_200x150, contentType: "image/png" }, + { + name: "settings", + content: JSON.stringify({ width: 100, unknownField: "foo", anotherUnknown: 42 }), + }, + ]); + + const res = await app.inject({ + method: "POST", + url: "/api/v1/tools/resize", + headers: { "content-type": contentType, authorization: `Bearer ${adminToken}` }, + body, + }); + + // Zod default behavior strips unknown keys — should succeed + expect(res.statusCode).toBe(200); + }); +}); diff --git a/tests/integration/favicon.test.ts b/tests/integration/favicon.test.ts new file mode 100644 index 00000000..bacc2ce7 --- /dev/null +++ b/tests/integration/favicon.test.ts @@ -0,0 +1,165 @@ +/** + * Integration tests for the favicon tool. + * + * Generates a ZIP archive containing favicon assets (multiple PNG sizes, + * ICO, manifest.json, HTML snippet). Custom route that streams a ZIP + * response via reply.hijack(). + */ + +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import AdmZip from "adm-zip"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { buildTestApp, createMultipartPayload, loginAsAdmin, type TestApp } from "./test-server.js"; + +const FIXTURES = join(__dirname, "..", "fixtures"); +const PNG = readFileSync(join(FIXTURES, "test-200x150.png")); + +let testApp: TestApp; +let app: TestApp["app"]; +let adminToken: string; + +beforeAll(async () => { + testApp = await buildTestApp(); + app = testApp.app; + adminToken = await loginAsAdmin(app); +}, 30_000); + +afterAll(async () => { + await testApp.cleanup(); +}, 10_000); + +describe("favicon", () => { + it("generates favicon ZIP from a single image", async () => { + const { body, contentType } = createMultipartPayload([ + { name: "file", filename: "logo.png", contentType: "image/png", content: PNG }, + ]); + + const res = await app.inject({ + method: "POST", + url: "/api/v1/tools/favicon", + headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType }, + body, + }); + + expect(res.statusCode).toBe(200); + expect(res.headers["content-type"]).toContain("application/zip"); + + // Parse the ZIP and verify expected files + const zipBuffer = Buffer.from(res.rawPayload); + const zip = new AdmZip(zipBuffer); + const entries = zip.getEntries().map((e) => e.entryName); + + expect(entries).toContain("favicon-16x16.png"); + expect(entries).toContain("favicon-32x32.png"); + expect(entries).toContain("favicon-48x48.png"); + expect(entries).toContain("apple-touch-icon.png"); + expect(entries).toContain("android-chrome-192x192.png"); + expect(entries).toContain("android-chrome-512x512.png"); + expect(entries).toContain("favicon.ico"); + expect(entries).toContain("manifest.json"); + expect(entries).toContain("favicon-snippet.html"); + }); + + it("manifest.json is valid JSON", async () => { + const { body, contentType } = createMultipartPayload([ + { name: "file", filename: "logo.png", contentType: "image/png", content: PNG }, + ]); + + const res = await app.inject({ + method: "POST", + url: "/api/v1/tools/favicon", + headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType }, + body, + }); + + expect(res.statusCode).toBe(200); + const zip = new AdmZip(Buffer.from(res.rawPayload)); + const manifestEntry = zip.getEntry("manifest.json"); + expect(manifestEntry).toBeDefined(); + const manifest = JSON.parse(manifestEntry!.getData().toString("utf-8")); + expect(manifest.icons).toBeDefined(); + expect(manifest.icons.length).toBeGreaterThan(0); + }); + + it("HTML snippet contains link tags", async () => { + const { body, contentType } = createMultipartPayload([ + { name: "file", filename: "logo.png", contentType: "image/png", content: PNG }, + ]); + + const res = await app.inject({ + method: "POST", + url: "/api/v1/tools/favicon", + headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType }, + body, + }); + + expect(res.statusCode).toBe(200); + const zip = new AdmZip(Buffer.from(res.rawPayload)); + const snippetEntry = zip.getEntry("favicon-snippet.html"); + expect(snippetEntry).toBeDefined(); + const html = snippetEntry!.getData().toString("utf-8"); + expect(html).toContain(' { + const { body, contentType } = createMultipartPayload([ + { name: "file", filename: "logo.png", contentType: "image/png", content: PNG }, + ]); + + const res = await app.inject({ + method: "POST", + url: "/api/v1/tools/favicon", + headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType }, + body, + }); + + expect(res.statusCode).toBe(200); + const zip = new AdmZip(Buffer.from(res.rawPayload)); + for (const name of ["favicon-16x16.png", "favicon-32x32.png", "favicon.ico"]) { + const entry = zip.getEntry(name); + expect(entry).toBeDefined(); + expect(entry!.header.size).toBeGreaterThan(0); + } + }); + + it("uses subfolder structure for multiple images", async () => { + const { body, contentType } = createMultipartPayload([ + { name: "file", filename: "icon1.png", contentType: "image/png", content: PNG }, + { name: "file", filename: "icon2.png", contentType: "image/png", content: PNG }, + ]); + + const res = await app.inject({ + method: "POST", + url: "/api/v1/tools/favicon", + headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType }, + body, + }); + + expect(res.statusCode).toBe(200); + const zip = new AdmZip(Buffer.from(res.rawPayload)); + const entries = zip.getEntries().map((e) => e.entryName); + // Multiple files get per-image subfolder prefixes + expect(entries.some((e) => e.startsWith("icon1/"))).toBe(true); + expect(entries.some((e) => e.startsWith("icon2/"))).toBe(true); + }); + + it("rejects request without any files", async () => { + const { body, contentType } = createMultipartPayload([ + { name: "settings", content: JSON.stringify({}) }, + ]); + + const res = await app.inject({ + method: "POST", + url: "/api/v1/tools/favicon", + headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType }, + body, + }); + + expect(res.statusCode).toBe(400); + const json = JSON.parse(res.body); + expect(json.error).toContain("No image file"); + }); +}); diff --git a/tests/integration/find-duplicates.test.ts b/tests/integration/find-duplicates.test.ts new file mode 100644 index 00000000..c635eae0 --- /dev/null +++ b/tests/integration/find-duplicates.test.ts @@ -0,0 +1,238 @@ +/** + * Integration tests for the find-duplicates tool (/api/v1/tools/find-duplicates). + * + * Covers duplicate detection with identical images, detection of unique images, + * threshold tuning, response structure, and input validation. + */ + +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { buildTestApp, createMultipartPayload, loginAsAdmin, type TestApp } from "./test-server.js"; + +const FIXTURES = join(__dirname, "..", "fixtures"); +const PNG = readFileSync(join(FIXTURES, "test-200x150.png")); +const JPG = readFileSync(join(FIXTURES, "test-100x100.jpg")); +const WEBP = readFileSync(join(FIXTURES, "test-50x50.webp")); +// Use a content photo that is perceptually very different from the test images +const PORTRAIT = readFileSync(join(FIXTURES, "content", "portrait-color.jpg")); + +let testApp: TestApp; +let app: TestApp["app"]; +let adminToken: string; + +beforeAll(async () => { + testApp = await buildTestApp(); + app = testApp.app; + adminToken = await loginAsAdmin(app); +}, 30_000); + +afterAll(async () => { + await testApp.cleanup(); +}, 10_000); + +describe("Find Duplicates", () => { + it("detects duplicates when the same image is uploaded twice", async () => { + const { body, contentType } = createMultipartPayload([ + { name: "file", filename: "copy1.png", contentType: "image/png", content: PNG }, + { name: "file", filename: "copy2.png", contentType: "image/png", content: PNG }, + ]); + + const res = await app.inject({ + method: "POST", + url: "/api/v1/tools/find-duplicates", + headers: { + authorization: `Bearer ${adminToken}`, + "content-type": contentType, + }, + body, + }); + + expect(res.statusCode).toBe(200); + const result = JSON.parse(res.body); + + expect(result.totalImages).toBe(2); + expect(result.duplicateGroups).toHaveLength(1); + expect(result.duplicateGroups[0].files).toHaveLength(2); + + // Identical images should have 100% similarity + const similarities = result.duplicateGroups[0].files.map( + (f: { similarity: number }) => f.similarity, + ); + expect(similarities).toContain(100); + + // One file should be marked as best + const bestFiles = result.duplicateGroups[0].files.filter((f: { isBest: boolean }) => f.isBest); + expect(bestFiles).toHaveLength(1); + }); + + it("reports no duplicate groups for completely different images", async () => { + const { body, contentType } = createMultipartPayload([ + { name: "file", filename: "a.png", contentType: "image/png", content: PNG }, + { name: "file", filename: "b.jpg", contentType: "image/jpeg", content: PORTRAIT }, + ]); + + const res = await app.inject({ + method: "POST", + url: "/api/v1/tools/find-duplicates", + headers: { + authorization: `Bearer ${adminToken}`, + "content-type": contentType, + }, + body, + }); + + expect(res.statusCode).toBe(200); + const result = JSON.parse(res.body); + + expect(result.totalImages).toBe(2); + expect(result.duplicateGroups).toHaveLength(0); + expect(result.uniqueImages).toBe(2); + expect(result.spaceSaveable).toBe(0); + }); + + it("detects duplicates among a mix of duplicate and unique images", async () => { + const { body, contentType } = createMultipartPayload([ + { name: "file", filename: "a.png", contentType: "image/png", content: PNG }, + { name: "file", filename: "b.png", contentType: "image/png", content: PNG }, + { name: "file", filename: "c.jpg", contentType: "image/jpeg", content: PORTRAIT }, + ]); + + const res = await app.inject({ + method: "POST", + url: "/api/v1/tools/find-duplicates", + headers: { + authorization: `Bearer ${adminToken}`, + "content-type": contentType, + }, + body, + }); + + expect(res.statusCode).toBe(200); + const result = JSON.parse(res.body); + + expect(result.totalImages).toBe(3); + expect(result.duplicateGroups).toHaveLength(1); + expect(result.duplicateGroups[0].files).toHaveLength(2); + // The portrait should be unique + expect(result.uniqueImages).toBe(1); + }); + + it("respects the threshold parameter", async () => { + const { body, contentType } = createMultipartPayload([ + { name: "file", filename: "a.png", contentType: "image/png", content: PNG }, + { name: "file", filename: "b.jpg", contentType: "image/jpeg", content: JPG }, + { name: "threshold", content: "0" }, + ]); + + const res = await app.inject({ + method: "POST", + url: "/api/v1/tools/find-duplicates", + headers: { + authorization: `Bearer ${adminToken}`, + "content-type": contentType, + }, + body, + }); + + expect(res.statusCode).toBe(200); + const result = JSON.parse(res.body); + + // With threshold 0, only exact hash matches should be grouped + // Different images should not be grouped + expect(result.totalImages).toBe(2); + }); + + it("calculates space saveable from duplicate groups", async () => { + const { body, contentType } = createMultipartPayload([ + { name: "file", filename: "a.png", contentType: "image/png", content: PNG }, + { name: "file", filename: "b.png", contentType: "image/png", content: PNG }, + ]); + + const res = await app.inject({ + method: "POST", + url: "/api/v1/tools/find-duplicates", + headers: { + authorization: `Bearer ${adminToken}`, + "content-type": contentType, + }, + body, + }); + + expect(res.statusCode).toBe(200); + const result = JSON.parse(res.body); + + // Space saveable should be the size of the non-best duplicate + expect(result.spaceSaveable).toBeGreaterThan(0); + }); + + it("includes file metadata in duplicate group entries", async () => { + const { body, contentType } = createMultipartPayload([ + { name: "file", filename: "img1.png", contentType: "image/png", content: PNG }, + { name: "file", filename: "img2.png", contentType: "image/png", content: PNG }, + ]); + + const res = await app.inject({ + method: "POST", + url: "/api/v1/tools/find-duplicates", + headers: { + authorization: `Bearer ${adminToken}`, + "content-type": contentType, + }, + body, + }); + + expect(res.statusCode).toBe(200); + const result = JSON.parse(res.body); + + const file = result.duplicateGroups[0].files[0]; + expect(file).toHaveProperty("filename"); + expect(file).toHaveProperty("similarity"); + expect(file).toHaveProperty("width"); + expect(file).toHaveProperty("height"); + expect(file).toHaveProperty("fileSize"); + expect(file).toHaveProperty("format"); + expect(file).toHaveProperty("isBest"); + expect(file).toHaveProperty("thumbnail"); + expect(file.width).toBe(200); + expect(file.height).toBe(150); + }); + + // ── Validation ────────────────────────────────────────────────────── + + it("rejects requests with fewer than 2 images", async () => { + const { body, contentType } = createMultipartPayload([ + { name: "file", filename: "solo.png", contentType: "image/png", content: PNG }, + ]); + + const res = await app.inject({ + method: "POST", + url: "/api/v1/tools/find-duplicates", + headers: { + authorization: `Bearer ${adminToken}`, + "content-type": contentType, + }, + body, + }); + + expect(res.statusCode).toBe(400); + const result = JSON.parse(res.body); + expect(result.error).toMatch(/at least 2/i); + }); + + it("rejects unauthenticated requests", async () => { + const { body, contentType } = createMultipartPayload([ + { name: "file", filename: "a.png", contentType: "image/png", content: PNG }, + { name: "file", filename: "b.png", contentType: "image/png", content: PNG }, + ]); + + const res = await app.inject({ + method: "POST", + url: "/api/v1/tools/find-duplicates", + headers: { "content-type": contentType }, + body, + }); + + expect(res.statusCode).toBe(401); + }); +}); diff --git a/tests/integration/format-matrix.test.ts b/tests/integration/format-matrix.test.ts new file mode 100644 index 00000000..cd57d8ce --- /dev/null +++ b/tests/integration/format-matrix.test.ts @@ -0,0 +1,347 @@ +/** + * Cross-format matrix integration test. + * + * For each supported input format, verifies that the most commonly used + * non-AI tools (resize, convert, info) work correctly via the API. + * + * Some formats (PSD, EXR, HDR, TGA, DNG) require CLI decoders (ImageMagick, + * dcraw) that may not be installed in every test environment. For those + * formats, the test accepts either 200 or 422 and documents the reason. + */ + +import { existsSync, readFileSync } from "node:fs"; +import { join } from "node:path"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { buildTestApp, createMultipartPayload, loginAsAdmin, type TestApp } from "./test-server.js"; + +const FORMATS_DIR = join(__dirname, "..", "fixtures", "formats"); + +// --------------------------------------------------------------------------- +// Format sample definitions +// --------------------------------------------------------------------------- +interface FormatSample { + name: string; + file: string; + mime: string; + /** True if format requires CLI decoder (ImageMagick / dcraw) */ + needsCliDecoder: boolean; + /** True if format requires libheif decoder */ + needsHeifDecoder: boolean; + /** + * True if Sharp may fail to read metadata for this format during + * validation, causing a 400 response. This happens for formats like + * BMP (some variants) and JXL where Sharp support is incomplete. + */ + mayFailValidation: boolean; +} + +const FORMAT_SAMPLES: FormatSample[] = [ + { + name: "JPEG", + file: "sample.jpg", + mime: "image/jpeg", + needsCliDecoder: false, + needsHeifDecoder: false, + mayFailValidation: false, + }, + { + name: "PNG", + file: "sample.png", + mime: "image/png", + needsCliDecoder: false, + needsHeifDecoder: false, + mayFailValidation: false, + }, + { + name: "WebP", + file: "sample.webp", + mime: "image/webp", + needsCliDecoder: false, + needsHeifDecoder: false, + mayFailValidation: false, + }, + { + name: "GIF", + file: "sample.gif", + mime: "image/gif", + needsCliDecoder: false, + needsHeifDecoder: false, + mayFailValidation: false, + }, + { + name: "AVIF", + file: "sample.avif", + mime: "image/avif", + needsCliDecoder: false, + needsHeifDecoder: false, + mayFailValidation: false, + }, + { + name: "TIFF", + file: "sample.tiff", + mime: "image/tiff", + needsCliDecoder: false, + needsHeifDecoder: false, + mayFailValidation: false, + }, + // BMP: Sharp can't parse all BMP variants; validation may fail with 400 + { + name: "BMP", + file: "sample.bmp", + mime: "image/bmp", + needsCliDecoder: false, + needsHeifDecoder: false, + mayFailValidation: true, + }, + { + name: "HEIC", + file: "sample.heic", + mime: "image/heic", + needsCliDecoder: false, + needsHeifDecoder: true, + mayFailValidation: false, + }, + { + name: "HEIF", + file: "sample.heif", + mime: "image/heif", + needsCliDecoder: false, + needsHeifDecoder: true, + mayFailValidation: false, + }, + { + name: "SVG", + file: "sample.svg", + mime: "image/svg+xml", + needsCliDecoder: false, + needsHeifDecoder: false, + mayFailValidation: false, + }, + { + name: "ICO", + file: "sample.ico", + mime: "image/x-icon", + needsCliDecoder: true, + needsHeifDecoder: false, + mayFailValidation: false, + }, + { + name: "PSD", + file: "sample.psd", + mime: "image/vnd.adobe.photoshop", + needsCliDecoder: true, + needsHeifDecoder: false, + mayFailValidation: false, + }, + { + name: "EXR", + file: "sample.exr", + mime: "image/x-exr", + needsCliDecoder: true, + needsHeifDecoder: false, + mayFailValidation: false, + }, + { + name: "HDR", + file: "sample.hdr", + mime: "image/vnd.radiance", + needsCliDecoder: true, + needsHeifDecoder: false, + mayFailValidation: false, + }, + { + name: "TGA", + file: "sample.tga", + mime: "image/x-tga", + needsCliDecoder: true, + needsHeifDecoder: false, + mayFailValidation: false, + }, + { + name: "DNG", + file: "sample.dng", + mime: "image/x-adobe-dng", + needsCliDecoder: true, + needsHeifDecoder: false, + mayFailValidation: false, + }, + // JXL: Sharp may not support JXL metadata reading; validation may fail with 400 + { + name: "JXL", + file: "sample.jxl", + mime: "image/jxl", + needsCliDecoder: true, + needsHeifDecoder: false, + mayFailValidation: true, + }, +]; + +// --------------------------------------------------------------------------- +// Shared state +// --------------------------------------------------------------------------- +let testApp: TestApp; +let app: TestApp["app"]; +let adminToken: string; + +// Only include formats whose fixture files actually exist +let availableFormats: FormatSample[]; + +beforeAll(async () => { + testApp = await buildTestApp(); + app = testApp.app; + adminToken = await loginAsAdmin(app); + + availableFormats = FORMAT_SAMPLES.filter((fmt) => existsSync(join(FORMATS_DIR, fmt.file))); +}, 30_000); + +afterAll(async () => { + await testApp.cleanup(); +}, 10_000); + +// --------------------------------------------------------------------------- +// Cross-format matrix +// --------------------------------------------------------------------------- +describe("Cross-format matrix", () => { + for (const fmt of FORMAT_SAMPLES) { + describe(`${fmt.name} input (${fmt.file})`, () => { + // Skip formats whose fixture files don't exist + const fixturePath = join(FORMATS_DIR, fmt.file); + + // ------------------------------------------------------------------ + // RESIZE: POST /api/v1/tools/resize with width=50, height=50 + // ------------------------------------------------------------------ + it("can be resized", async () => { + if (!existsSync(fixturePath)) return; + + const buffer = readFileSync(fixturePath); + const { body: payload, contentType } = createMultipartPayload([ + { + name: "file", + filename: fmt.file, + contentType: fmt.mime, + content: buffer, + }, + { + name: "settings", + content: JSON.stringify({ width: 50, height: 50 }), + }, + ]); + + const res = await app.inject({ + method: "POST", + url: "/api/v1/tools/resize", + headers: { + authorization: `Bearer ${adminToken}`, + "content-type": contentType, + }, + body: payload, + }); + + // CLI-decoded formats may return 422 if ImageMagick / decoder not available. + // Formats with incomplete Sharp support may return 400 from validation. + if (fmt.needsCliDecoder || fmt.needsHeifDecoder || fmt.mayFailValidation) { + expect([200, 400, 422]).toContain(res.statusCode); + } else { + expect(res.statusCode).toBe(200); + } + + if (res.statusCode === 200) { + const body = JSON.parse(res.body); + expect(body.downloadUrl).toBeDefined(); + expect(body.processedSize).toBeGreaterThan(0); + } + }); + + // ------------------------------------------------------------------ + // INFO: POST /api/v1/tools/info — metadata extraction + // ------------------------------------------------------------------ + it("can get info", async () => { + if (!existsSync(fixturePath)) return; + + const buffer = readFileSync(fixturePath); + const { body: payload, contentType } = createMultipartPayload([ + { + name: "file", + filename: fmt.file, + contentType: fmt.mime, + content: buffer, + }, + ]); + + const res = await app.inject({ + method: "POST", + url: "/api/v1/tools/info", + headers: { + authorization: `Bearer ${adminToken}`, + "content-type": contentType, + }, + body: payload, + }); + + // CLI-decoded formats may return 422 if decoder not available. + // Formats with incomplete Sharp support may return 400 from validation. + if (fmt.needsCliDecoder || fmt.needsHeifDecoder || fmt.mayFailValidation) { + expect([200, 400, 422]).toContain(res.statusCode); + } else { + expect(res.statusCode).toBe(200); + } + + if (res.statusCode === 200) { + const body = JSON.parse(res.body); + expect(body.width).toBeGreaterThan(0); + expect(body.height).toBeGreaterThan(0); + expect(body.fileSize).toBeGreaterThan(0); + expect(body.format).toBeDefined(); + } + }); + + // ------------------------------------------------------------------ + // CONVERT: POST /api/v1/tools/convert to PNG output + // (Skip if input is already PNG) + // ------------------------------------------------------------------ + if (fmt.name !== "PNG") { + it("can be converted to PNG", async () => { + if (!existsSync(fixturePath)) return; + + const buffer = readFileSync(fixturePath); + const { body: payload, contentType } = createMultipartPayload([ + { + name: "file", + filename: fmt.file, + contentType: fmt.mime, + content: buffer, + }, + { + name: "settings", + content: JSON.stringify({ format: "png" }), + }, + ]); + + const res = await app.inject({ + method: "POST", + url: "/api/v1/tools/convert", + headers: { + authorization: `Bearer ${adminToken}`, + "content-type": contentType, + }, + body: payload, + }); + + // CLI-decoded formats may return 422 if decoder not available. + // Formats with incomplete Sharp support may return 400 from validation. + if (fmt.needsCliDecoder || fmt.needsHeifDecoder || fmt.mayFailValidation) { + expect([200, 400, 422]).toContain(res.statusCode); + } else { + expect(res.statusCode).toBe(200); + } + + if (res.statusCode === 200) { + const body = JSON.parse(res.body); + expect(body.downloadUrl).toContain(".png"); + expect(body.processedSize).toBeGreaterThan(0); + } + }); + } + }); + } +}); diff --git a/tests/integration/image-enhancement.test.ts b/tests/integration/image-enhancement.test.ts new file mode 100644 index 00000000..3a513cfc --- /dev/null +++ b/tests/integration/image-enhancement.test.ts @@ -0,0 +1,310 @@ +/** + * Integration tests for the image-enhancement tool. + * + * This is a Sharp-based tool (not AI sidecar) that analyzes and auto-enhances + * images. Tests all modes (auto, portrait, landscape, low-light, food, document), + * intensity parameter, selective corrections, and the analyze endpoint. + */ + +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import sharp from "sharp"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { buildTestApp, createMultipartPayload, loginAsAdmin, type TestApp } from "./test-server.js"; + +const FIXTURES = join(__dirname, "..", "fixtures"); +const PNG = readFileSync(join(FIXTURES, "test-200x150.png")); +const JPG = readFileSync(join(FIXTURES, "test-100x100.jpg")); +const WEBP = readFileSync(join(FIXTURES, "test-50x50.webp")); + +let testApp: TestApp; +let app: TestApp["app"]; +let adminToken: string; + +beforeAll(async () => { + testApp = await buildTestApp(); + app = testApp.app; + adminToken = await loginAsAdmin(app); +}, 30_000); + +afterAll(async () => { + await testApp.cleanup(); +}, 10_000); + +function makePayload( + settings: Record, + buffer: Buffer = PNG, + filename = "test.png", + contentType = "image/png", +) { + return createMultipartPayload([ + { name: "file", filename, contentType, content: buffer }, + { name: "settings", content: JSON.stringify(settings) }, + ]); +} + +async function postTool( + settings: Record, + buffer?: Buffer, + filename?: string, + ct?: string, +) { + const { body: payload, contentType } = makePayload(settings, buffer, filename, ct); + return app.inject({ + method: "POST", + url: "/api/v1/tools/image-enhancement", + payload, + headers: { + "content-type": contentType, + authorization: `Bearer ${adminToken}`, + }, + }); +} + +// ── Auto mode (default) ─────────────────────────────────────────── +describe("Auto mode", () => { + it("enhances with default settings", async () => { + const res = await postTool({}); + expect(res.statusCode).toBe(200); + const result = JSON.parse(res.body); + expect(result.downloadUrl).toBeDefined(); + expect(result.processedSize).toBeGreaterThan(0); + }); + + it("enhances with explicit auto mode", async () => { + const res = await postTool({ mode: "auto" }); + expect(res.statusCode).toBe(200); + const result = JSON.parse(res.body); + expect(result.downloadUrl).toBeDefined(); + }); +}); + +// ── All enhancement modes ───────────────────────────────────────── +describe("Enhancement modes", () => { + it("enhances in portrait mode", async () => { + const res = await postTool({ mode: "portrait" }); + expect(res.statusCode).toBe(200); + const result = JSON.parse(res.body); + expect(result.downloadUrl).toBeDefined(); + }); + + it("enhances in landscape mode", async () => { + const res = await postTool({ mode: "landscape" }); + expect(res.statusCode).toBe(200); + const result = JSON.parse(res.body); + expect(result.downloadUrl).toBeDefined(); + }); + + it("enhances in low-light mode", async () => { + const res = await postTool({ mode: "low-light" }); + expect(res.statusCode).toBe(200); + const result = JSON.parse(res.body); + expect(result.downloadUrl).toBeDefined(); + }); + + it("enhances in food mode", async () => { + const res = await postTool({ mode: "food" }); + expect(res.statusCode).toBe(200); + const result = JSON.parse(res.body); + expect(result.downloadUrl).toBeDefined(); + }); + + it("enhances in document mode", async () => { + const res = await postTool({ mode: "document" }); + expect(res.statusCode).toBe(200); + const result = JSON.parse(res.body); + expect(result.downloadUrl).toBeDefined(); + }); +}); + +// ── Intensity parameter ─────────────────────────────────────────── +describe("Intensity parameter", () => { + it("enhances at minimum intensity (0)", async () => { + const res = await postTool({ intensity: 0 }); + expect(res.statusCode).toBe(200); + const result = JSON.parse(res.body); + expect(result.downloadUrl).toBeDefined(); + }); + + it("enhances at maximum intensity (100)", async () => { + const res = await postTool({ intensity: 100 }); + expect(res.statusCode).toBe(200); + const result = JSON.parse(res.body); + expect(result.downloadUrl).toBeDefined(); + }); + + it("enhances at mid intensity (50, default)", async () => { + const res = await postTool({ intensity: 50 }); + expect(res.statusCode).toBe(200); + }); +}); + +// ── Selective corrections ───────────────────────────────────────── +describe("Selective corrections", () => { + it("disables all corrections except exposure", async () => { + const res = await postTool({ + corrections: { + exposure: true, + contrast: false, + whiteBalance: false, + saturation: false, + sharpness: false, + denoise: false, + }, + }); + expect(res.statusCode).toBe(200); + const result = JSON.parse(res.body); + expect(result.downloadUrl).toBeDefined(); + }); + + it("enables only sharpness and denoise", async () => { + const res = await postTool({ + corrections: { + exposure: false, + contrast: false, + whiteBalance: false, + saturation: false, + sharpness: true, + denoise: true, + }, + }); + expect(res.statusCode).toBe(200); + const result = JSON.parse(res.body); + expect(result.downloadUrl).toBeDefined(); + }); + + it("disables all corrections", async () => { + const res = await postTool({ + corrections: { + exposure: false, + contrast: false, + whiteBalance: false, + saturation: false, + sharpness: false, + denoise: false, + }, + }); + expect(res.statusCode).toBe(200); + }); +}); + +// ── Output verification ────────────────────────────────────────── +describe("Output verification", () => { + it("output differs from input", async () => { + const res = await postTool({ mode: "auto", intensity: 80 }); + expect(res.statusCode).toBe(200); + const result = JSON.parse(res.body); + + const dlRes = await app.inject({ + method: "GET", + url: result.downloadUrl, + headers: { authorization: `Bearer ${adminToken}` }, + }); + expect(dlRes.statusCode).toBe(200); + expect(Buffer.compare(dlRes.rawPayload, PNG)).not.toBe(0); + }); + + it("preserves image dimensions", async () => { + const res = await postTool({ mode: "auto" }); + expect(res.statusCode).toBe(200); + const result = JSON.parse(res.body); + + const dlRes = await app.inject({ + method: "GET", + url: result.downloadUrl, + headers: { authorization: `Bearer ${adminToken}` }, + }); + const meta = await sharp(dlRes.rawPayload).metadata(); + expect(meta.width).toBe(200); + expect(meta.height).toBe(150); + }); +}); + +// ── Analyze endpoint ────────────────────────────────────────────── +describe("Analyze endpoint", () => { + it("returns analysis data for an image", async () => { + const { body: payload, contentType } = createMultipartPayload([ + { name: "file", filename: "test.png", contentType: "image/png", content: PNG }, + ]); + const res = await app.inject({ + method: "POST", + url: "/api/v1/tools/image-enhancement/analyze", + payload, + headers: { + "content-type": contentType, + authorization: `Bearer ${adminToken}`, + }, + }); + expect(res.statusCode).toBe(200); + const result = JSON.parse(res.body); + // Analysis should return corrections object + expect(result.corrections).toBeDefined(); + }); + + it("analyze returns 400 when no file is provided", async () => { + const { body: payload, contentType } = createMultipartPayload([ + { name: "other", content: "nothing" }, + ]); + const res = await app.inject({ + method: "POST", + url: "/api/v1/tools/image-enhancement/analyze", + payload, + headers: { + "content-type": contentType, + authorization: `Bearer ${adminToken}`, + }, + }); + expect(res.statusCode).toBe(400); + }); +}); + +// ── Multiple input formats ──────────────────────────────────────── +describe("Multiple input formats", () => { + it("enhances JPEG input", async () => { + const res = await postTool({ mode: "auto" }, JPG, "test.jpg", "image/jpeg"); + expect(res.statusCode).toBe(200); + const result = JSON.parse(res.body); + expect(result.downloadUrl).toBeDefined(); + }); + + it("enhances WebP input", async () => { + const res = await postTool({ mode: "auto" }, WEBP, "test.webp", "image/webp"); + expect(res.statusCode).toBe(200); + const result = JSON.parse(res.body); + expect(result.downloadUrl).toBeDefined(); + }); +}); + +// ── Error handling ──────────────────────────────────────────────── +describe("Error handling", () => { + it("returns 400 when no file is provided", async () => { + const { body: payload, contentType } = createMultipartPayload([ + { name: "settings", content: JSON.stringify({ mode: "auto" }) }, + ]); + const res = await app.inject({ + method: "POST", + url: "/api/v1/tools/image-enhancement", + payload, + headers: { + "content-type": contentType, + authorization: `Bearer ${adminToken}`, + }, + }); + expect(res.statusCode).toBe(400); + }); + + it("returns 400 for invalid mode", async () => { + const res = await postTool({ mode: "hdr" }); + expect(res.statusCode).toBe(400); + }); + + it("returns 400 for intensity out of range (negative)", async () => { + const res = await postTool({ intensity: -10 }); + expect(res.statusCode).toBe(400); + }); + + it("returns 400 for intensity out of range (>100)", async () => { + const res = await postTool({ intensity: 150 }); + expect(res.statusCode).toBe(400); + }); +}); diff --git a/tests/integration/image-to-base64.test.ts b/tests/integration/image-to-base64.test.ts new file mode 100644 index 00000000..3cee9c6e --- /dev/null +++ b/tests/integration/image-to-base64.test.ts @@ -0,0 +1,196 @@ +/** + * Integration tests for the image-to-base64 tool. + * + * Converts image(s) to base64 strings. Custom route that returns JSON + * with results/errors arrays rather than a file download. + */ + +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { buildTestApp, createMultipartPayload, loginAsAdmin, type TestApp } from "./test-server.js"; + +const FIXTURES = join(__dirname, "..", "fixtures"); +const PNG = readFileSync(join(FIXTURES, "test-200x150.png")); +const JPG = readFileSync(join(FIXTURES, "test-100x100.jpg")); + +let testApp: TestApp; +let app: TestApp["app"]; +let adminToken: string; + +beforeAll(async () => { + testApp = await buildTestApp(); + app = testApp.app; + adminToken = await loginAsAdmin(app); +}, 30_000); + +afterAll(async () => { + await testApp.cleanup(); +}, 10_000); + +describe("image-to-base64", () => { + it("converts a PNG to base64", async () => { + const { body, contentType } = createMultipartPayload([ + { name: "file", filename: "test.png", contentType: "image/png", content: PNG }, + { name: "settings", content: JSON.stringify({}) }, + ]); + + const res = await app.inject({ + method: "POST", + url: "/api/v1/tools/image-to-base64", + headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType }, + body, + }); + + expect(res.statusCode).toBe(200); + const json = JSON.parse(res.body); + expect(json.results).toHaveLength(1); + expect(json.errors).toHaveLength(0); + + const result = json.results[0]; + expect(result.filename).toBe("test.png"); + expect(result.base64).toBeDefined(); + expect(result.base64.length).toBeGreaterThan(0); + expect(result.dataUri).toMatch(/^data:image\/.+;base64,/); + expect(result.mimeType).toContain("image/"); + expect(result.width).toBeGreaterThan(0); + expect(result.height).toBeGreaterThan(0); + expect(result.originalSize).toBe(PNG.length); + expect(result.encodedSize).toBeGreaterThan(0); + expect(typeof result.overheadPercent).toBe("number"); + }); + + it("base64 output is valid", async () => { + const { body, contentType } = createMultipartPayload([ + { name: "file", filename: "test.png", contentType: "image/png", content: PNG }, + { name: "settings", content: JSON.stringify({}) }, + ]); + + const res = await app.inject({ + method: "POST", + url: "/api/v1/tools/image-to-base64", + headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType }, + body, + }); + + expect(res.statusCode).toBe(200); + const json = JSON.parse(res.body); + const b64 = json.results[0].base64; + // Verify it decodes without error + const decoded = Buffer.from(b64, "base64"); + expect(decoded.length).toBeGreaterThan(0); + // Re-encoding should match + expect(decoded.toString("base64")).toBe(b64); + }); + + it("converts multiple images", async () => { + const { body, contentType } = createMultipartPayload([ + { name: "file", filename: "img1.png", contentType: "image/png", content: PNG }, + { name: "file", filename: "img2.jpg", contentType: "image/jpeg", content: JPG }, + { name: "settings", content: JSON.stringify({}) }, + ]); + + const res = await app.inject({ + method: "POST", + url: "/api/v1/tools/image-to-base64", + headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType }, + body, + }); + + expect(res.statusCode).toBe(200); + const json = JSON.parse(res.body); + expect(json.results).toHaveLength(2); + expect(json.results[0].filename).toBe("img1.png"); + expect(json.results[1].filename).toBe("img2.jpg"); + }); + + it("converts to a specific output format", async () => { + const { body, contentType } = createMultipartPayload([ + { name: "file", filename: "test.png", contentType: "image/png", content: PNG }, + { name: "settings", content: JSON.stringify({ outputFormat: "jpeg" }) }, + ]); + + const res = await app.inject({ + method: "POST", + url: "/api/v1/tools/image-to-base64", + headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType }, + body, + }); + + expect(res.statusCode).toBe(200); + const json = JSON.parse(res.body); + expect(json.results[0].mimeType).toBe("image/jpeg"); + expect(json.results[0].dataUri).toMatch(/^data:image\/jpeg;base64,/); + }); + + it("applies maxWidth resize", async () => { + const { body, contentType } = createMultipartPayload([ + { name: "file", filename: "test.png", contentType: "image/png", content: PNG }, + { name: "settings", content: JSON.stringify({ maxWidth: 50 }) }, + ]); + + const res = await app.inject({ + method: "POST", + url: "/api/v1/tools/image-to-base64", + headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType }, + body, + }); + + expect(res.statusCode).toBe(200); + const json = JSON.parse(res.body); + expect(json.results[0].width).toBeLessThanOrEqual(50); + }); + + it("applies maxHeight resize", async () => { + const { body, contentType } = createMultipartPayload([ + { name: "file", filename: "test.png", contentType: "image/png", content: PNG }, + { name: "settings", content: JSON.stringify({ maxHeight: 50 }) }, + ]); + + const res = await app.inject({ + method: "POST", + url: "/api/v1/tools/image-to-base64", + headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType }, + body, + }); + + expect(res.statusCode).toBe(200); + const json = JSON.parse(res.body); + expect(json.results[0].height).toBeLessThanOrEqual(50); + }); + + it("rejects request without any files", async () => { + const { body, contentType } = createMultipartPayload([ + { name: "settings", content: JSON.stringify({}) }, + ]); + + const res = await app.inject({ + method: "POST", + url: "/api/v1/tools/image-to-base64", + headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType }, + body, + }); + + expect(res.statusCode).toBe(400); + const json = JSON.parse(res.body); + expect(json.error).toContain("No image files"); + }); + + it("rejects invalid quality setting", async () => { + const { body, contentType } = createMultipartPayload([ + { name: "file", filename: "test.png", contentType: "image/png", content: PNG }, + { name: "settings", content: JSON.stringify({ quality: 0 }) }, + ]); + + const res = await app.inject({ + method: "POST", + url: "/api/v1/tools/image-to-base64", + headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType }, + body, + }); + + expect(res.statusCode).toBe(400); + const json = JSON.parse(res.body); + expect(json.error).toContain("Invalid settings"); + }); +}); diff --git a/tests/integration/image-to-pdf.test.ts b/tests/integration/image-to-pdf.test.ts new file mode 100644 index 00000000..425dd2f4 --- /dev/null +++ b/tests/integration/image-to-pdf.test.ts @@ -0,0 +1,196 @@ +/** + * Integration tests for the image-to-pdf tool. + * + * Converts one or more images into a PDF document. Custom route that + * accepts multiple file uploads and settings for page size, orientation, + * and margin. + */ + +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { buildTestApp, createMultipartPayload, loginAsAdmin, type TestApp } from "./test-server.js"; + +const FIXTURES = join(__dirname, "..", "fixtures"); +const PNG = readFileSync(join(FIXTURES, "test-200x150.png")); +const JPG = readFileSync(join(FIXTURES, "test-100x100.jpg")); + +let testApp: TestApp; +let app: TestApp["app"]; +let adminToken: string; + +beforeAll(async () => { + testApp = await buildTestApp(); + app = testApp.app; + adminToken = await loginAsAdmin(app); +}, 30_000); + +afterAll(async () => { + await testApp.cleanup(); +}, 10_000); + +describe("image-to-pdf", () => { + it("converts a single image to PDF", async () => { + const { body, contentType } = createMultipartPayload([ + { name: "file", filename: "test.png", contentType: "image/png", content: PNG }, + { name: "settings", content: JSON.stringify({}) }, + ]); + + const res = await app.inject({ + method: "POST", + url: "/api/v1/tools/image-to-pdf", + headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType }, + body, + }); + + expect(res.statusCode).toBe(200); + const json = JSON.parse(res.body); + expect(json.downloadUrl).toBeDefined(); + expect(json.jobId).toBeDefined(); + expect(json.pages).toBe(1); + expect(json.processedSize).toBeGreaterThan(0); + }); + + it("output is valid PDF (magic bytes)", async () => { + const { body, contentType } = createMultipartPayload([ + { name: "file", filename: "test.png", contentType: "image/png", content: PNG }, + { name: "settings", content: JSON.stringify({}) }, + ]); + + const res = await app.inject({ + method: "POST", + url: "/api/v1/tools/image-to-pdf", + headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType }, + body, + }); + + expect(res.statusCode).toBe(200); + const json = JSON.parse(res.body); + + const dlRes = await app.inject({ + method: "GET", + url: json.downloadUrl, + headers: { authorization: `Bearer ${adminToken}` }, + }); + expect(dlRes.statusCode).toBe(200); + const pdfBuffer = Buffer.from(dlRes.rawPayload); + // PDF files start with %PDF- + expect(pdfBuffer.subarray(0, 5).toString("ascii")).toBe("%PDF-"); + }); + + it("converts multiple images to multi-page PDF", async () => { + const { body, contentType } = createMultipartPayload([ + { name: "file", filename: "page1.png", contentType: "image/png", content: PNG }, + { name: "file", filename: "page2.jpg", contentType: "image/jpeg", content: JPG }, + { name: "settings", content: JSON.stringify({}) }, + ]); + + const res = await app.inject({ + method: "POST", + url: "/api/v1/tools/image-to-pdf", + headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType }, + body, + }); + + expect(res.statusCode).toBe(200); + const json = JSON.parse(res.body); + expect(json.pages).toBe(2); + }); + + it.each(["A4", "Letter", "A3", "A5"] as const)("supports page size: %s", async (pageSize) => { + const { body, contentType } = createMultipartPayload([ + { name: "file", filename: "test.png", contentType: "image/png", content: PNG }, + { name: "settings", content: JSON.stringify({ pageSize }) }, + ]); + + const res = await app.inject({ + method: "POST", + url: "/api/v1/tools/image-to-pdf", + headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType }, + body, + }); + + expect(res.statusCode).toBe(200); + }); + + it("supports landscape orientation", async () => { + const { body, contentType } = createMultipartPayload([ + { name: "file", filename: "test.png", contentType: "image/png", content: PNG }, + { name: "settings", content: JSON.stringify({ orientation: "landscape" }) }, + ]); + + const res = await app.inject({ + method: "POST", + url: "/api/v1/tools/image-to-pdf", + headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType }, + body, + }); + + expect(res.statusCode).toBe(200); + }); + + it("supports custom margin", async () => { + const { body, contentType } = createMultipartPayload([ + { name: "file", filename: "test.png", contentType: "image/png", content: PNG }, + { name: "settings", content: JSON.stringify({ margin: 50 }) }, + ]); + + const res = await app.inject({ + method: "POST", + url: "/api/v1/tools/image-to-pdf", + headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType }, + body, + }); + + expect(res.statusCode).toBe(200); + }); + + it("rejects request without any files", async () => { + const { body, contentType } = createMultipartPayload([ + { name: "settings", content: JSON.stringify({}) }, + ]); + + const res = await app.inject({ + method: "POST", + url: "/api/v1/tools/image-to-pdf", + headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType }, + body, + }); + + expect(res.statusCode).toBe(400); + const json = JSON.parse(res.body); + expect(json.error).toContain("No image files"); + }); + + it("rejects invalid settings JSON", async () => { + const { body, contentType } = createMultipartPayload([ + { name: "file", filename: "test.png", contentType: "image/png", content: PNG }, + { name: "settings", content: "not-json" }, + ]); + + const res = await app.inject({ + method: "POST", + url: "/api/v1/tools/image-to-pdf", + headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType }, + body, + }); + + expect(res.statusCode).toBe(400); + }); + + it("rejects margin out of range", async () => { + const { body, contentType } = createMultipartPayload([ + { name: "file", filename: "test.png", contentType: "image/png", content: PNG }, + { name: "settings", content: JSON.stringify({ margin: 999 }) }, + ]); + + const res = await app.inject({ + method: "POST", + url: "/api/v1/tools/image-to-pdf", + headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType }, + body, + }); + + expect(res.statusCode).toBe(400); + }); +}); diff --git a/tests/integration/info.test.ts b/tests/integration/info.test.ts new file mode 100644 index 00000000..ee0e99f8 --- /dev/null +++ b/tests/integration/info.test.ts @@ -0,0 +1,271 @@ +/** + * Integration tests for the info tool (/api/v1/tools/info). + * + * Covers image metadata extraction: dimensions, format, color space, + * histogram, EXIF presence, and input validation. + */ + +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { buildTestApp, createMultipartPayload, loginAsAdmin, type TestApp } from "./test-server.js"; + +const FIXTURES = join(__dirname, "..", "fixtures"); +const PNG = readFileSync(join(FIXTURES, "test-200x150.png")); +const JPG = readFileSync(join(FIXTURES, "test-100x100.jpg")); +const WEBP = readFileSync(join(FIXTURES, "test-50x50.webp")); +const EXIF_JPG = readFileSync(join(FIXTURES, "test-with-exif.jpg")); +const TINY_PNG = readFileSync(join(FIXTURES, "test-1x1.png")); + +let testApp: TestApp; +let app: TestApp["app"]; +let adminToken: string; + +beforeAll(async () => { + testApp = await buildTestApp(); + app = testApp.app; + adminToken = await loginAsAdmin(app); +}, 30_000); + +afterAll(async () => { + await testApp.cleanup(); +}, 10_000); + +describe("Info", () => { + it("returns correct metadata for a PNG image", async () => { + const { body, contentType } = createMultipartPayload([ + { name: "file", filename: "test.png", contentType: "image/png", content: PNG }, + ]); + + const res = await app.inject({ + method: "POST", + url: "/api/v1/tools/info", + headers: { + authorization: `Bearer ${adminToken}`, + "content-type": contentType, + }, + body, + }); + + expect(res.statusCode).toBe(200); + const result = JSON.parse(res.body); + + expect(result.width).toBe(200); + expect(result.height).toBe(150); + expect(result.format).toBe("png"); + expect(result.filename).toBe("test.png"); + expect(result.fileSize).toBeGreaterThan(0); + expect(result.channels).toBeGreaterThanOrEqual(3); + expect(typeof result.hasAlpha).toBe("boolean"); + expect(typeof result.colorSpace).toBe("string"); + expect(result.pages).toBe(1); + }); + + it("returns correct metadata for a JPEG image", async () => { + const { body, contentType } = createMultipartPayload([ + { name: "file", filename: "test.jpg", contentType: "image/jpeg", content: JPG }, + ]); + + const res = await app.inject({ + method: "POST", + url: "/api/v1/tools/info", + headers: { + authorization: `Bearer ${adminToken}`, + "content-type": contentType, + }, + body, + }); + + expect(res.statusCode).toBe(200); + const result = JSON.parse(res.body); + + expect(result.width).toBe(100); + expect(result.height).toBe(100); + expect(result.format).toBe("jpeg"); + expect(result.filename).toBe("test.jpg"); + }); + + it("returns correct metadata for a WebP image", async () => { + const { body, contentType } = createMultipartPayload([ + { name: "file", filename: "test.webp", contentType: "image/webp", content: WEBP }, + ]); + + const res = await app.inject({ + method: "POST", + url: "/api/v1/tools/info", + headers: { + authorization: `Bearer ${adminToken}`, + "content-type": contentType, + }, + body, + }); + + expect(res.statusCode).toBe(200); + const result = JSON.parse(res.body); + + expect(result.width).toBe(50); + expect(result.height).toBe(50); + expect(result.format).toBe("webp"); + }); + + it("returns correct metadata for a 1x1 pixel image", async () => { + const { body, contentType } = createMultipartPayload([ + { name: "file", filename: "tiny.png", contentType: "image/png", content: TINY_PNG }, + ]); + + const res = await app.inject({ + method: "POST", + url: "/api/v1/tools/info", + headers: { + authorization: `Bearer ${adminToken}`, + "content-type": contentType, + }, + body, + }); + + expect(res.statusCode).toBe(200); + const result = JSON.parse(res.body); + + expect(result.width).toBe(1); + expect(result.height).toBe(1); + }); + + it("detects EXIF data on test-with-exif.jpg", async () => { + const { body, contentType } = createMultipartPayload([ + { + name: "file", + filename: "test-with-exif.jpg", + contentType: "image/jpeg", + content: EXIF_JPG, + }, + ]); + + const res = await app.inject({ + method: "POST", + url: "/api/v1/tools/info", + headers: { + authorization: `Bearer ${adminToken}`, + "content-type": contentType, + }, + body, + }); + + expect(res.statusCode).toBe(200); + const result = JSON.parse(res.body); + + expect(result.hasExif).toBe(true); + expect(result.format).toBe("jpeg"); + }); + + it("includes histogram data with channel stats", async () => { + const { body, contentType } = createMultipartPayload([ + { name: "file", filename: "test.png", contentType: "image/png", content: PNG }, + ]); + + const res = await app.inject({ + method: "POST", + url: "/api/v1/tools/info", + headers: { + authorization: `Bearer ${adminToken}`, + "content-type": contentType, + }, + body, + }); + + expect(res.statusCode).toBe(200); + const result = JSON.parse(res.body); + + expect(result.histogram).toBeDefined(); + expect(Array.isArray(result.histogram)).toBe(true); + expect(result.histogram.length).toBeGreaterThanOrEqual(3); + + for (const channel of result.histogram) { + expect(typeof channel.channel).toBe("string"); + expect(typeof channel.min).toBe("number"); + expect(typeof channel.max).toBe("number"); + expect(typeof channel.mean).toBe("number"); + expect(typeof channel.stdev).toBe("number"); + expect(channel.min).toBeGreaterThanOrEqual(0); + expect(channel.max).toBeLessThanOrEqual(255); + } + }); + + it("returns all expected fields in the response", async () => { + const { body, contentType } = createMultipartPayload([ + { name: "file", filename: "test.png", contentType: "image/png", content: PNG }, + ]); + + const res = await app.inject({ + method: "POST", + url: "/api/v1/tools/info", + headers: { + authorization: `Bearer ${adminToken}`, + "content-type": contentType, + }, + body, + }); + + expect(res.statusCode).toBe(200); + const result = JSON.parse(res.body); + + const expectedKeys = [ + "filename", + "fileSize", + "width", + "height", + "format", + "channels", + "hasAlpha", + "colorSpace", + "density", + "isProgressive", + "orientation", + "hasProfile", + "hasExif", + "hasIcc", + "hasXmp", + "bitDepth", + "pages", + "histogram", + ]; + + for (const key of expectedKeys) { + expect(result).toHaveProperty(key); + } + }); + + // ── Validation ────────────────────────────────────────────────────── + + it("rejects requests without a file", async () => { + const { body, contentType } = createMultipartPayload([{ name: "dummy", content: "nothing" }]); + + const res = await app.inject({ + method: "POST", + url: "/api/v1/tools/info", + headers: { + authorization: `Bearer ${adminToken}`, + "content-type": contentType, + }, + body, + }); + + expect(res.statusCode).toBe(400); + const result = JSON.parse(res.body); + expect(result.error).toMatch(/no image/i); + }); + + it("rejects unauthenticated requests", async () => { + const { body, contentType } = createMultipartPayload([ + { name: "file", filename: "test.png", contentType: "image/png", content: PNG }, + ]); + + const res = await app.inject({ + method: "POST", + url: "/api/v1/tools/info", + headers: { "content-type": contentType }, + body, + }); + + expect(res.statusCode).toBe(401); + }); +}); diff --git a/tests/integration/optimize-for-web.test.ts b/tests/integration/optimize-for-web.test.ts new file mode 100644 index 00000000..3566c447 --- /dev/null +++ b/tests/integration/optimize-for-web.test.ts @@ -0,0 +1,318 @@ +/** + * Integration tests for the optimize-for-web tool. + * + * Tests format conversion (webp, jpeg, avif, png), quality control, + * max dimension resizing, progressive encoding, metadata stripping, + * and the preview endpoint. + */ + +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import sharp from "sharp"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { buildTestApp, createMultipartPayload, loginAsAdmin, type TestApp } from "./test-server.js"; + +const FIXTURES = join(__dirname, "..", "fixtures"); +const PNG = readFileSync(join(FIXTURES, "test-200x150.png")); +const JPG = readFileSync(join(FIXTURES, "test-100x100.jpg")); +const WEBP = readFileSync(join(FIXTURES, "test-50x50.webp")); + +let testApp: TestApp; +let app: TestApp["app"]; +let adminToken: string; + +beforeAll(async () => { + testApp = await buildTestApp(); + app = testApp.app; + adminToken = await loginAsAdmin(app); +}, 30_000); + +afterAll(async () => { + await testApp.cleanup(); +}, 10_000); + +function makePayload( + settings: Record, + buffer: Buffer = PNG, + filename = "test.png", + contentType = "image/png", +) { + return createMultipartPayload([ + { name: "file", filename, contentType, content: buffer }, + { name: "settings", content: JSON.stringify(settings) }, + ]); +} + +async function postTool( + settings: Record, + buffer?: Buffer, + filename?: string, + ct?: string, +) { + const { body: payload, contentType } = makePayload(settings, buffer, filename, ct); + return app.inject({ + method: "POST", + url: "/api/v1/tools/optimize-for-web", + payload, + headers: { + "content-type": contentType, + authorization: `Bearer ${adminToken}`, + }, + }); +} + +// ── Default optimization ────────────────────────────────────────── +describe("Default optimization", () => { + it("optimizes PNG with default settings (webp, quality 80)", async () => { + const res = await postTool({}); + expect(res.statusCode).toBe(200); + const result = JSON.parse(res.body); + expect(result.downloadUrl).toBeDefined(); + expect(result.processedSize).toBeGreaterThan(0); + }); +}); + +// ── Output format options ───────────────────────────────────────── +describe("Output format", () => { + it("outputs as WebP", async () => { + const res = await postTool({ format: "webp" }); + expect(res.statusCode).toBe(200); + const result = JSON.parse(res.body); + + const dlRes = await app.inject({ + method: "GET", + url: result.downloadUrl, + headers: { authorization: `Bearer ${adminToken}` }, + }); + const meta = await sharp(dlRes.rawPayload).metadata(); + expect(meta.format).toBe("webp"); + }); + + it("outputs as JPEG", async () => { + const res = await postTool({ format: "jpeg" }); + expect(res.statusCode).toBe(200); + const result = JSON.parse(res.body); + + const dlRes = await app.inject({ + method: "GET", + url: result.downloadUrl, + headers: { authorization: `Bearer ${adminToken}` }, + }); + const meta = await sharp(dlRes.rawPayload).metadata(); + expect(meta.format).toBe("jpeg"); + }); + + it("outputs as AVIF", async () => { + const res = await postTool({ format: "avif" }); + expect(res.statusCode).toBe(200); + const result = JSON.parse(res.body); + + const dlRes = await app.inject({ + method: "GET", + url: result.downloadUrl, + headers: { authorization: `Bearer ${adminToken}` }, + }); + const meta = await sharp(dlRes.rawPayload).metadata(); + expect(meta.format).toBe("heif"); + }); + + it("outputs as PNG", async () => { + const res = await postTool({ format: "png" }); + expect(res.statusCode).toBe(200); + const result = JSON.parse(res.body); + + const dlRes = await app.inject({ + method: "GET", + url: result.downloadUrl, + headers: { authorization: `Bearer ${adminToken}` }, + }); + const meta = await sharp(dlRes.rawPayload).metadata(); + expect(meta.format).toBe("png"); + }); + + it("renames output file extension to match format", async () => { + const res = await postTool({ format: "webp" }); + expect(res.statusCode).toBe(200); + const result = JSON.parse(res.body); + expect(result.downloadUrl).toContain(".webp"); + }); +}); + +// ── Quality control ─────────────────────────────────────────────── +describe("Quality control", () => { + it("lower quality produces smaller file", async () => { + const res90 = await postTool({ format: "jpeg", quality: 95 }); + const res10 = await postTool({ format: "jpeg", quality: 10 }); + expect(res90.statusCode).toBe(200); + expect(res10.statusCode).toBe(200); + const result90 = JSON.parse(res90.body); + const result10 = JSON.parse(res10.body); + expect(result10.processedSize).toBeLessThanOrEqual(result90.processedSize); + }); +}); + +// ── Max dimension resizing ──────────────────────────────────────── +describe("Max dimension resizing", () => { + it("constrains width with maxWidth", async () => { + const res = await postTool({ format: "webp", maxWidth: 100 }); + expect(res.statusCode).toBe(200); + const result = JSON.parse(res.body); + + const dlRes = await app.inject({ + method: "GET", + url: result.downloadUrl, + headers: { authorization: `Bearer ${adminToken}` }, + }); + const meta = await sharp(dlRes.rawPayload).metadata(); + expect(meta.width).toBeLessThanOrEqual(100); + }); + + it("constrains height with maxHeight", async () => { + const res = await postTool({ format: "webp", maxHeight: 50 }); + expect(res.statusCode).toBe(200); + const result = JSON.parse(res.body); + + const dlRes = await app.inject({ + method: "GET", + url: result.downloadUrl, + headers: { authorization: `Bearer ${adminToken}` }, + }); + const meta = await sharp(dlRes.rawPayload).metadata(); + expect(meta.height).toBeLessThanOrEqual(50); + }); + + it("constrains both maxWidth and maxHeight", async () => { + const res = await postTool({ format: "png", maxWidth: 80, maxHeight: 60 }); + expect(res.statusCode).toBe(200); + const result = JSON.parse(res.body); + + const dlRes = await app.inject({ + method: "GET", + url: result.downloadUrl, + headers: { authorization: `Bearer ${adminToken}` }, + }); + const meta = await sharp(dlRes.rawPayload).metadata(); + expect(meta.width).toBeLessThanOrEqual(80); + expect(meta.height).toBeLessThanOrEqual(60); + }); +}); + +// ── Metadata stripping ─────────────────────────────────────────── +describe("Metadata stripping", () => { + it("strips metadata by default (stripMetadata=true)", async () => { + const exifJpg = readFileSync(join(FIXTURES, "test-with-exif.jpg")); + const res = await postTool( + { format: "jpeg", stripMetadata: true }, + exifJpg, + "test-with-exif.jpg", + "image/jpeg", + ); + expect(res.statusCode).toBe(200); + const result = JSON.parse(res.body); + + const dlRes = await app.inject({ + method: "GET", + url: result.downloadUrl, + headers: { authorization: `Bearer ${adminToken}` }, + }); + const meta = await sharp(dlRes.rawPayload).metadata(); + expect(!meta.exif || meta.exif.length === 0).toBe(true); + }); +}); + +// ── Multiple input formats ──────────────────────────────────────── +describe("Multiple input formats", () => { + it("optimizes JPEG input", async () => { + const res = await postTool({ format: "webp" }, JPG, "test.jpg", "image/jpeg"); + expect(res.statusCode).toBe(200); + const result = JSON.parse(res.body); + expect(result.downloadUrl).toBeDefined(); + }); + + it("optimizes WebP input", async () => { + const res = await postTool({ format: "jpeg" }, WEBP, "test.webp", "image/webp"); + expect(res.statusCode).toBe(200); + const result = JSON.parse(res.body); + expect(result.downloadUrl).toBeDefined(); + }); +}); + +// ── Preview endpoint ────────────────────────────────────────────── +describe("Preview endpoint", () => { + it("returns binary image with size headers", async () => { + const { body: payload, contentType } = makePayload({ format: "webp", quality: 60 }); + const res = await app.inject({ + method: "POST", + url: "/api/v1/tools/optimize-for-web/preview", + payload, + headers: { + "content-type": contentType, + authorization: `Bearer ${adminToken}`, + }, + }); + expect(res.statusCode).toBe(200); + expect(res.headers["content-type"]).toContain("image/webp"); + expect(res.headers["x-original-size"]).toBeDefined(); + expect(res.headers["x-processed-size"]).toBeDefined(); + expect(res.headers["x-output-filename"]).toBeDefined(); + + // Verify the response is a valid image + const meta = await sharp(res.rawPayload).metadata(); + expect(meta.format).toBe("webp"); + }); + + it("preview returns 400 when no file is provided", async () => { + const { body: payload, contentType } = createMultipartPayload([ + { name: "settings", content: JSON.stringify({ format: "webp" }) }, + ]); + const res = await app.inject({ + method: "POST", + url: "/api/v1/tools/optimize-for-web/preview", + payload, + headers: { + "content-type": contentType, + authorization: `Bearer ${adminToken}`, + }, + }); + expect(res.statusCode).toBe(400); + }); +}); + +// ── Error handling ──────────────────────────────────────────────── +describe("Error handling", () => { + it("returns 400 when no file is provided", async () => { + const { body: payload, contentType } = createMultipartPayload([ + { name: "settings", content: JSON.stringify({ format: "webp" }) }, + ]); + const res = await app.inject({ + method: "POST", + url: "/api/v1/tools/optimize-for-web", + payload, + headers: { + "content-type": contentType, + authorization: `Bearer ${adminToken}`, + }, + }); + expect(res.statusCode).toBe(400); + }); + + it("returns 400 for invalid format", async () => { + const res = await postTool({ format: "bmp" }); + expect(res.statusCode).toBe(400); + }); + + it("returns 400 for quality out of range (0)", async () => { + const res = await postTool({ quality: 0 }); + expect(res.statusCode).toBe(400); + }); + + it("returns 400 for quality out of range (101)", async () => { + const res = await postTool({ quality: 101 }); + expect(res.statusCode).toBe(400); + }); + + it("returns 400 for negative maxWidth", async () => { + const res = await postTool({ maxWidth: -100 }); + expect(res.statusCode).toBe(400); + }); +}); diff --git a/tests/integration/pipeline-edge-cases.test.ts b/tests/integration/pipeline-edge-cases.test.ts new file mode 100644 index 00000000..ed7c9f95 --- /dev/null +++ b/tests/integration/pipeline-edge-cases.test.ts @@ -0,0 +1,358 @@ +/** + * Pipeline edge-case integration tests for the ashim image API. + * + * Tests the pipeline execution system with unusual inputs: empty pipelines, + * single steps, invalid tools, conflicting steps, and multi-step chains. + */ + +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { buildTestApp, createMultipartPayload, loginAsAdmin, type TestApp } from "./test-server.js"; + +// --------------------------------------------------------------------------- +// Fixtures +// --------------------------------------------------------------------------- +const FIXTURES = join(__dirname, "..", "fixtures"); +const PNG_200x150 = readFileSync(join(FIXTURES, "test-200x150.png")); + +// --------------------------------------------------------------------------- +// Shared state +// --------------------------------------------------------------------------- +let testApp: TestApp; +let app: TestApp["app"]; +let adminToken: string; + +beforeAll(async () => { + testApp = await buildTestApp(); + app = testApp.app; + adminToken = await loginAsAdmin(app); +}, 30_000); + +afterAll(async () => { + await testApp.cleanup(); +}, 10_000); + +/** Helper to POST a pipeline execution request. */ +function executePipeline( + image: Buffer, + filename: string, + pipeline: { steps: Array<{ toolId: string; settings?: Record }> }, +) { + const { body, contentType } = createMultipartPayload([ + { name: "file", filename, content: image, contentType: "image/png" }, + { name: "pipeline", content: JSON.stringify(pipeline) }, + ]); + return app.inject({ + method: "POST", + url: "/api/v1/pipeline/execute", + headers: { "content-type": contentType, authorization: `Bearer ${adminToken}` }, + body, + }); +} + +// ═══════════════════════════════════════════════════════════════════════════ +// EMPTY PIPELINE +// ═══════════════════════════════════════════════════════════════════════════ +describe("Empty pipeline", () => { + it("rejects a pipeline with zero steps", async () => { + const res = await executePipeline(PNG_200x150, "test.png", { steps: [] }); + + expect(res.statusCode).toBe(400); + const json = JSON.parse(res.body); + expect(json.error).toMatch(/pipeline/i); + }); +}); + +// ═══════════════════════════════════════════════════════════════════════════ +// SINGLE STEP PIPELINE +// ═══════════════════════════════════════════════════════════════════════════ +describe("Single step pipeline", () => { + it("executes a pipeline with one resize step", async () => { + const res = await executePipeline(PNG_200x150, "test.png", { + steps: [{ toolId: "resize", settings: { width: 100 } }], + }); + + expect(res.statusCode).toBe(200); + const json = JSON.parse(res.body); + expect(json.stepsCompleted).toBe(1); + expect(json.downloadUrl).toBeDefined(); + }); + + it("executes a pipeline with one rotate step", async () => { + const res = await executePipeline(PNG_200x150, "test.png", { + steps: [{ toolId: "rotate", settings: { angle: 180 } }], + }); + + expect(res.statusCode).toBe(200); + const json = JSON.parse(res.body); + expect(json.stepsCompleted).toBe(1); + }); +}); + +// ═══════════════════════════════════════════════════════════════════════════ +// PIPELINE WITH INVALID TOOL +// ═══════════════════════════════════════════════════════════════════════════ +describe("Pipeline with invalid tool reference", () => { + it("rejects a pipeline referencing a non-existent tool", async () => { + const res = await executePipeline(PNG_200x150, "test.png", { + steps: [{ toolId: "totally-fake-tool-that-does-not-exist" }], + }); + + expect(res.statusCode).toBe(400); + const json = JSON.parse(res.body); + expect(json.error).toMatch(/not found/i); + }); + + it("rejects at the invalid step even when earlier steps are valid", async () => { + const res = await executePipeline(PNG_200x150, "test.png", { + steps: [{ toolId: "resize", settings: { width: 100 } }, { toolId: "nonexistent-tool" }], + }); + + // Validation happens before execution — should reject immediately + expect(res.statusCode).toBe(400); + const json = JSON.parse(res.body); + expect(json.error).toMatch(/step 2/i); + }); +}); + +// ═══════════════════════════════════════════════════════════════════════════ +// PIPELINE WITH CONFLICTING STEPS +// ═══════════════════════════════════════════════════════════════════════════ +describe("Pipeline with conflicting steps", () => { + it("applies resize steps in order (last wins)", async () => { + const res = await executePipeline(PNG_200x150, "test.png", { + steps: [ + { toolId: "resize", settings: { width: 100, height: 100 } }, + { toolId: "resize", settings: { width: 200, height: 200 } }, + ], + }); + + expect(res.statusCode).toBe(200); + const json = JSON.parse(res.body); + expect(json.stepsCompleted).toBe(2); + }); +}); + +// ═══════════════════════════════════════════════════════════════════════════ +// PIPELINE WITH MULTIPLE TOOLS CHAINED +// ═══════════════════════════════════════════════════════════════════════════ +describe("Multi-step pipeline chains", () => { + it("chains resize then rotate then compress", async () => { + const res = await executePipeline(PNG_200x150, "test.png", { + steps: [ + { toolId: "resize", settings: { width: 100 } }, + { toolId: "rotate", settings: { angle: 90 } }, + { toolId: "compress", settings: { quality: 60 } }, + ], + }); + + expect(res.statusCode).toBe(200); + const json = JSON.parse(res.body); + expect(json.stepsCompleted).toBe(3); + expect(json.steps).toHaveLength(3); + expect(json.steps[0].toolId).toBe("resize"); + expect(json.steps[1].toolId).toBe("rotate"); + expect(json.steps[2].toolId).toBe("compress"); + }); + + it("chains crop then resize", async () => { + const res = await executePipeline(PNG_200x150, "test.png", { + steps: [ + { toolId: "crop", settings: { left: 10, top: 10, width: 100, height: 100 } }, + { toolId: "resize", settings: { width: 50 } }, + ], + }); + + expect(res.statusCode).toBe(200); + const json = JSON.parse(res.body); + expect(json.stepsCompleted).toBe(2); + // Output should be smaller than the original + expect(json.processedSize).toBeLessThan(json.originalSize); + }); +}); + +// ═══════════════════════════════════════════════════════════════════════════ +// PIPELINE WITH INVALID SETTINGS IN A STEP +// ═══════════════════════════════════════════════════════════════════════════ +describe("Pipeline with invalid settings in a step", () => { + it("rejects a pipeline with invalid settings for a specific step", async () => { + const res = await executePipeline(PNG_200x150, "test.png", { + steps: [ + { toolId: "resize", settings: { width: 100 } }, + { toolId: "crop", settings: { left: -10, top: 0, width: 50, height: 50 } }, + ], + }); + + // left: -10 fails z.number().min(0) validation + expect(res.statusCode).toBe(400); + const json = JSON.parse(res.body); + expect(json.error).toMatch(/step 2/i); + }); +}); + +// ═══════════════════════════════════════════════════════════════════════════ +// PIPELINE DEFINITION FIELD MISSING +// ═══════════════════════════════════════════════════════════════════════════ +describe("Missing pipeline definition", () => { + it("rejects when no pipeline field is provided", async () => { + const { body, contentType } = createMultipartPayload([ + { name: "file", filename: "test.png", content: PNG_200x150, contentType: "image/png" }, + ]); + + const res = await app.inject({ + method: "POST", + url: "/api/v1/pipeline/execute", + headers: { "content-type": contentType, authorization: `Bearer ${adminToken}` }, + body, + }); + + expect(res.statusCode).toBe(400); + const json = JSON.parse(res.body); + expect(json.error).toMatch(/pipeline/i); + }); + + it("rejects non-JSON pipeline definition", async () => { + const { body, contentType } = createMultipartPayload([ + { name: "file", filename: "test.png", content: PNG_200x150, contentType: "image/png" }, + { name: "pipeline", content: "this is not json" }, + ]); + + const res = await app.inject({ + method: "POST", + url: "/api/v1/pipeline/execute", + headers: { "content-type": contentType, authorization: `Bearer ${adminToken}` }, + body, + }); + + expect(res.statusCode).toBe(400); + const json = JSON.parse(res.body); + expect(json.error).toMatch(/json/i); + }); +}); + +// ═══════════════════════════════════════════════════════════════════════════ +// PIPELINE WITHOUT IMAGE +// ═══════════════════════════════════════════════════════════════════════════ +describe("Pipeline without image", () => { + it("rejects when no image file is provided", async () => { + const { body, contentType } = createMultipartPayload([ + { + name: "pipeline", + content: JSON.stringify({ steps: [{ toolId: "resize", settings: { width: 100 } }] }), + }, + ]); + + const res = await app.inject({ + method: "POST", + url: "/api/v1/pipeline/execute", + headers: { "content-type": contentType, authorization: `Bearer ${adminToken}` }, + body, + }); + + expect(res.statusCode).toBe(400); + const json = JSON.parse(res.body); + expect(json.error).toMatch(/no image/i); + }); +}); + +// ═══════════════════════════════════════════════════════════════════════════ +// SAVE PIPELINE EDGE CASES +// ═══════════════════════════════════════════════════════════════════════════ +describe("Save pipeline edge cases", () => { + it("rejects saving a pipeline with empty name", async () => { + const res = await app.inject({ + method: "POST", + url: "/api/v1/pipeline/save", + headers: { + "content-type": "application/json", + authorization: `Bearer ${adminToken}`, + }, + payload: { + name: "", + steps: [{ toolId: "resize", settings: { width: 100 } }], + }, + }); + + expect(res.statusCode).toBe(400); + }); + + it("rejects saving a pipeline with no steps", async () => { + const res = await app.inject({ + method: "POST", + url: "/api/v1/pipeline/save", + headers: { + "content-type": "application/json", + authorization: `Bearer ${adminToken}`, + }, + payload: { + name: "empty-pipeline", + steps: [], + }, + }); + + expect(res.statusCode).toBe(400); + }); + + it("rejects saving a pipeline with an invalid tool reference", async () => { + const res = await app.inject({ + method: "POST", + url: "/api/v1/pipeline/save", + headers: { + "content-type": "application/json", + authorization: `Bearer ${adminToken}`, + }, + payload: { + name: "bad-pipeline", + steps: [{ toolId: "this-tool-does-not-exist" }], + }, + }); + + expect(res.statusCode).toBe(400); + }); + + it("saves and lists a valid pipeline", async () => { + const saveRes = await app.inject({ + method: "POST", + url: "/api/v1/pipeline/save", + headers: { + "content-type": "application/json", + authorization: `Bearer ${adminToken}`, + }, + payload: { + name: "edge-case-test-pipeline", + description: "Created by edge case tests", + steps: [ + { toolId: "resize", settings: { width: 100 } }, + { toolId: "rotate", settings: { angle: 90 } }, + ], + }, + }); + + expect(saveRes.statusCode).toBe(201); + const saved = JSON.parse(saveRes.body); + expect(saved.id).toBeDefined(); + expect(saved.name).toBe("edge-case-test-pipeline"); + + // Verify it appears in the list + const listRes = await app.inject({ + method: "GET", + url: "/api/v1/pipeline/list", + headers: { authorization: `Bearer ${adminToken}` }, + }); + + expect(listRes.statusCode).toBe(200); + const list = JSON.parse(listRes.body); + const found = list.pipelines.find((p: { id: string }) => p.id === saved.id); + expect(found).toBeDefined(); + expect(found.steps).toHaveLength(2); + + // Clean up — delete the pipeline + const deleteRes = await app.inject({ + method: "DELETE", + url: `/api/v1/pipeline/${saved.id}`, + headers: { authorization: `Bearer ${adminToken}` }, + }); + expect(deleteRes.statusCode).toBe(200); + }); +}); diff --git a/tests/integration/qr-generate.test.ts b/tests/integration/qr-generate.test.ts new file mode 100644 index 00000000..338ce0a3 --- /dev/null +++ b/tests/integration/qr-generate.test.ts @@ -0,0 +1,306 @@ +/** + * Integration tests for the qr-generate tool (/api/v1/tools/qr-generate). + * + * Covers QR code generation from text/URL input, custom size and colors, + * error correction levels, download verification, and input validation. + */ + +import { join } from "node:path"; +import sharp from "sharp"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { buildTestApp, loginAsAdmin, type TestApp } from "./test-server.js"; + +let testApp: TestApp; +let app: TestApp["app"]; +let adminToken: string; + +beforeAll(async () => { + testApp = await buildTestApp(); + app = testApp.app; + adminToken = await loginAsAdmin(app); +}, 30_000); + +afterAll(async () => { + await testApp.cleanup(); +}, 10_000); + +describe("QR Generate", () => { + it("generates a QR code from a URL string", async () => { + const res = await app.inject({ + method: "POST", + url: "/api/v1/tools/qr-generate", + headers: { + authorization: `Bearer ${adminToken}`, + "content-type": "application/json", + }, + payload: { + text: "https://example.com", + }, + }); + + expect(res.statusCode).toBe(200); + const result = JSON.parse(res.body); + + expect(result.jobId).toBeDefined(); + expect(result.downloadUrl).toBeDefined(); + expect(result.processedSize).toBeGreaterThan(0); + }); + + it("generates a downloadable PNG image", async () => { + const res = await app.inject({ + method: "POST", + url: "/api/v1/tools/qr-generate", + headers: { + authorization: `Bearer ${adminToken}`, + "content-type": "application/json", + }, + payload: { + text: "Hello World", + }, + }); + + expect(res.statusCode).toBe(200); + const result = JSON.parse(res.body); + + // Download the QR code image + const dlRes = await app.inject({ + method: "GET", + url: result.downloadUrl, + }); + + expect(dlRes.statusCode).toBe(200); + const meta = await sharp(dlRes.rawPayload).metadata(); + expect(meta.format).toBe("png"); + expect(meta.width).toBeGreaterThan(0); + expect(meta.height).toBeGreaterThan(0); + }); + + it("respects the custom size parameter", async () => { + const res = await app.inject({ + method: "POST", + url: "/api/v1/tools/qr-generate", + headers: { + authorization: `Bearer ${adminToken}`, + "content-type": "application/json", + }, + payload: { + text: "test", + size: 800, + }, + }); + + expect(res.statusCode).toBe(200); + const result = JSON.parse(res.body); + + const dlRes = await app.inject({ + method: "GET", + url: result.downloadUrl, + }); + + const meta = await sharp(dlRes.rawPayload).metadata(); + expect(meta.width).toBe(800); + expect(meta.height).toBe(800); + }); + + it("uses default size of 400 when not specified", async () => { + const res = await app.inject({ + method: "POST", + url: "/api/v1/tools/qr-generate", + headers: { + authorization: `Bearer ${adminToken}`, + "content-type": "application/json", + }, + payload: { + text: "default size test", + }, + }); + + expect(res.statusCode).toBe(200); + const result = JSON.parse(res.body); + + const dlRes = await app.inject({ + method: "GET", + url: result.downloadUrl, + }); + + const meta = await sharp(dlRes.rawPayload).metadata(); + expect(meta.width).toBe(400); + expect(meta.height).toBe(400); + }); + + it("accepts custom foreground and background colors", async () => { + const res = await app.inject({ + method: "POST", + url: "/api/v1/tools/qr-generate", + headers: { + authorization: `Bearer ${adminToken}`, + "content-type": "application/json", + }, + payload: { + text: "colored QR", + foreground: "#FF0000", + background: "#00FF00", + }, + }); + + expect(res.statusCode).toBe(200); + const result = JSON.parse(res.body); + expect(result.downloadUrl).toBeDefined(); + expect(result.processedSize).toBeGreaterThan(0); + }); + + it("accepts all error correction levels", async () => { + for (const level of ["L", "M", "Q", "H"]) { + const res = await app.inject({ + method: "POST", + url: "/api/v1/tools/qr-generate", + headers: { + authorization: `Bearer ${adminToken}`, + "content-type": "application/json", + }, + payload: { + text: `EC level ${level}`, + errorCorrection: level, + }, + }); + + expect(res.statusCode).toBe(200); + } + }); + + it("returns all expected fields in the response", async () => { + const res = await app.inject({ + method: "POST", + url: "/api/v1/tools/qr-generate", + headers: { + authorization: `Bearer ${adminToken}`, + "content-type": "application/json", + }, + payload: { + text: "structure test", + }, + }); + + expect(res.statusCode).toBe(200); + const result = JSON.parse(res.body); + + expect(result).toHaveProperty("jobId"); + expect(result).toHaveProperty("downloadUrl"); + expect(result).toHaveProperty("originalSize"); + expect(result).toHaveProperty("processedSize"); + expect(result.originalSize).toBe(0); + }); + + // ── Validation ────────────────────────────────────────────────────── + + it("rejects requests without text", async () => { + const res = await app.inject({ + method: "POST", + url: "/api/v1/tools/qr-generate", + headers: { + authorization: `Bearer ${adminToken}`, + "content-type": "application/json", + }, + payload: {}, + }); + + expect(res.statusCode).toBe(400); + const result = JSON.parse(res.body); + expect(result.error).toMatch(/invalid settings/i); + }); + + it("rejects empty text", async () => { + const res = await app.inject({ + method: "POST", + url: "/api/v1/tools/qr-generate", + headers: { + authorization: `Bearer ${adminToken}`, + "content-type": "application/json", + }, + payload: { text: "" }, + }); + + expect(res.statusCode).toBe(400); + }); + + it("rejects invalid color format", async () => { + const res = await app.inject({ + method: "POST", + url: "/api/v1/tools/qr-generate", + headers: { + authorization: `Bearer ${adminToken}`, + "content-type": "application/json", + }, + payload: { + text: "test", + foreground: "red", + }, + }); + + expect(res.statusCode).toBe(400); + const result = JSON.parse(res.body); + expect(result.error).toMatch(/invalid settings/i); + }); + + it("rejects size below minimum", async () => { + const res = await app.inject({ + method: "POST", + url: "/api/v1/tools/qr-generate", + headers: { + authorization: `Bearer ${adminToken}`, + "content-type": "application/json", + }, + payload: { + text: "test", + size: 50, + }, + }); + + expect(res.statusCode).toBe(400); + }); + + it("rejects size above maximum", async () => { + const res = await app.inject({ + method: "POST", + url: "/api/v1/tools/qr-generate", + headers: { + authorization: `Bearer ${adminToken}`, + "content-type": "application/json", + }, + payload: { + text: "test", + size: 20000, + }, + }); + + expect(res.statusCode).toBe(400); + }); + + it("rejects invalid error correction level", async () => { + const res = await app.inject({ + method: "POST", + url: "/api/v1/tools/qr-generate", + headers: { + authorization: `Bearer ${adminToken}`, + "content-type": "application/json", + }, + payload: { + text: "test", + errorCorrection: "X", + }, + }); + + expect(res.statusCode).toBe(400); + }); + + it("rejects unauthenticated requests", async () => { + const res = await app.inject({ + method: "POST", + url: "/api/v1/tools/qr-generate", + headers: { "content-type": "application/json" }, + payload: { text: "test" }, + }); + + expect(res.statusCode).toBe(401); + }); +}); diff --git a/tests/integration/replace-color.test.ts b/tests/integration/replace-color.test.ts new file mode 100644 index 00000000..c60cc542 --- /dev/null +++ b/tests/integration/replace-color.test.ts @@ -0,0 +1,217 @@ +/** + * Integration tests for the replace-color tool. + * + * Replaces a source color with a target color (or makes it transparent). + * Tests valid replacements, tolerance parameter, makeTransparent mode, + * and verifies pixel-level changes. + */ + +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import sharp from "sharp"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { buildTestApp, createMultipartPayload, loginAsAdmin, type TestApp } from "./test-server.js"; + +const FIXTURES = join(__dirname, "..", "fixtures"); +const PNG = readFileSync(join(FIXTURES, "test-200x150.png")); +const JPG = readFileSync(join(FIXTURES, "test-100x100.jpg")); + +let testApp: TestApp; +let app: TestApp["app"]; +let adminToken: string; +let solidRedBuffer: Buffer; + +beforeAll(async () => { + testApp = await buildTestApp(); + app = testApp.app; + adminToken = await loginAsAdmin(app); + + // Create a solid red test image for predictable color replacement + solidRedBuffer = await sharp({ + create: { width: 50, height: 50, channels: 3, background: { r: 255, g: 0, b: 0 } }, + }) + .png() + .toBuffer(); +}, 30_000); + +afterAll(async () => { + await testApp.cleanup(); +}, 10_000); + +function makePayload( + settings: Record, + buffer: Buffer = PNG, + filename = "test.png", + contentType = "image/png", +) { + return createMultipartPayload([ + { name: "file", filename, contentType, content: buffer }, + { name: "settings", content: JSON.stringify(settings) }, + ]); +} + +async function postTool( + settings: Record, + buffer?: Buffer, + filename?: string, + ct?: string, +) { + const { body: payload, contentType } = makePayload(settings, buffer, filename, ct); + return app.inject({ + method: "POST", + url: "/api/v1/tools/replace-color", + payload, + headers: { + "content-type": contentType, + authorization: `Bearer ${adminToken}`, + }, + }); +} + +// ── Basic replacement ───────────────────────────────────────────── +describe("Basic color replacement", () => { + it("replaces red with blue in a solid red image", async () => { + const res = await postTool( + { sourceColor: "#FF0000", targetColor: "#0000FF", tolerance: 30 }, + solidRedBuffer, + ); + expect(res.statusCode).toBe(200); + const result = JSON.parse(res.body); + expect(result.downloadUrl).toBeDefined(); + expect(result.processedSize).toBeGreaterThan(0); + + // Download and verify color changed + const dlRes = await app.inject({ + method: "GET", + url: result.downloadUrl, + headers: { authorization: `Bearer ${adminToken}` }, + }); + expect(dlRes.statusCode).toBe(200); + + // Check that the output pixels are predominantly blue + const { data } = await sharp(dlRes.rawPayload) + .removeAlpha() + .raw() + .toBuffer({ resolveWithObject: true }); + // First pixel should be blue-ish (high B, low R) + expect(data[2]).toBeGreaterThan(data[0]); // blue > red + }); + + it("processes with default settings", async () => { + const res = await postTool({}); + expect(res.statusCode).toBe(200); + const result = JSON.parse(res.body); + expect(result.downloadUrl).toBeDefined(); + }); +}); + +// ── Tolerance ───────────────────────────────────────────────────── +describe("Tolerance parameter", () => { + it("with tolerance=0, only exact matches are replaced", async () => { + const res = await postTool( + { sourceColor: "#FF0000", targetColor: "#00FF00", tolerance: 0 }, + solidRedBuffer, + ); + expect(res.statusCode).toBe(200); + const result = JSON.parse(res.body); + expect(result.downloadUrl).toBeDefined(); + }); + + it("with high tolerance, more colors are affected", async () => { + const res = await postTool( + { sourceColor: "#FF0000", targetColor: "#00FF00", tolerance: 200 }, + solidRedBuffer, + ); + expect(res.statusCode).toBe(200); + const result = JSON.parse(res.body); + expect(result.downloadUrl).toBeDefined(); + }); + + it("with max tolerance=255, all pixels are affected", async () => { + const res = await postTool({ + sourceColor: "#FF0000", + targetColor: "#00FF00", + tolerance: 255, + }); + expect(res.statusCode).toBe(200); + }); +}); + +// ── Make transparent ────────────────────────────────────────────── +describe("Make transparent mode", () => { + it("makes matching pixels transparent instead of replacing", async () => { + const res = await postTool( + { sourceColor: "#FF0000", makeTransparent: true, tolerance: 30 }, + solidRedBuffer, + ); + expect(res.statusCode).toBe(200); + const result = JSON.parse(res.body); + expect(result.downloadUrl).toBeDefined(); + + // Download and verify alpha channel + const dlRes = await app.inject({ + method: "GET", + url: result.downloadUrl, + headers: { authorization: `Bearer ${adminToken}` }, + }); + const meta = await sharp(dlRes.rawPayload).metadata(); + expect(meta.channels).toBe(4); // RGBA output + }); +}); + +// ── Format support ──────────────────────────────────────────────── +describe("Multiple input formats", () => { + it("processes JPEG input", async () => { + const res = await postTool( + { sourceColor: "#808080", targetColor: "#FF0000", tolerance: 50 }, + JPG, + "test.jpg", + "image/jpeg", + ); + expect(res.statusCode).toBe(200); + const result = JSON.parse(res.body); + expect(result.downloadUrl).toBeDefined(); + }); +}); + +// ── Error handling ──────────────────────────────────────────────── +describe("Error handling", () => { + it("returns 400 when no file is provided", async () => { + const { body: payload, contentType } = createMultipartPayload([ + { + name: "settings", + content: JSON.stringify({ sourceColor: "#FF0000", targetColor: "#00FF00" }), + }, + ]); + const res = await app.inject({ + method: "POST", + url: "/api/v1/tools/replace-color", + payload, + headers: { + "content-type": contentType, + authorization: `Bearer ${adminToken}`, + }, + }); + expect(res.statusCode).toBe(400); + }); + + it("returns 400 for invalid hex color format", async () => { + const res = await postTool({ sourceColor: "red", targetColor: "#00FF00" }); + expect(res.statusCode).toBe(400); + }); + + it("returns 400 for short hex color", async () => { + const res = await postTool({ sourceColor: "#F00", targetColor: "#00FF00" }); + expect(res.statusCode).toBe(400); + }); + + it("returns 400 for tolerance out of range (negative)", async () => { + const res = await postTool({ tolerance: -1 }); + expect(res.statusCode).toBe(400); + }); + + it("returns 400 for tolerance out of range (>255)", async () => { + const res = await postTool({ tolerance: 300 }); + expect(res.statusCode).toBe(400); + }); +}); diff --git a/tests/integration/sharpening.test.ts b/tests/integration/sharpening.test.ts new file mode 100644 index 00000000..4cc0ea05 --- /dev/null +++ b/tests/integration/sharpening.test.ts @@ -0,0 +1,246 @@ +/** + * Integration tests for the sharpening tool. + * + * Tests all three sharpening methods (adaptive, unsharp-mask, high-pass), + * custom parameters, denoise option, and format support. + */ + +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import sharp from "sharp"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { buildTestApp, createMultipartPayload, loginAsAdmin, type TestApp } from "./test-server.js"; + +const FIXTURES = join(__dirname, "..", "fixtures"); +const PNG = readFileSync(join(FIXTURES, "test-200x150.png")); +const JPG = readFileSync(join(FIXTURES, "test-100x100.jpg")); +const WEBP = readFileSync(join(FIXTURES, "test-50x50.webp")); + +let testApp: TestApp; +let app: TestApp["app"]; +let adminToken: string; + +beforeAll(async () => { + testApp = await buildTestApp(); + app = testApp.app; + adminToken = await loginAsAdmin(app); +}, 30_000); + +afterAll(async () => { + await testApp.cleanup(); +}, 10_000); + +function makePayload( + settings: Record, + buffer: Buffer = PNG, + filename = "test.png", + contentType = "image/png", +) { + return createMultipartPayload([ + { name: "file", filename, contentType, content: buffer }, + { name: "settings", content: JSON.stringify(settings) }, + ]); +} + +async function postTool( + settings: Record, + buffer?: Buffer, + filename?: string, + ct?: string, +) { + const { body: payload, contentType } = makePayload(settings, buffer, filename, ct); + return app.inject({ + method: "POST", + url: "/api/v1/tools/sharpening", + payload, + headers: { + "content-type": contentType, + authorization: `Bearer ${adminToken}`, + }, + }); +} + +// ── Adaptive method (default) ───────────────────────────────────── +describe("Adaptive sharpening", () => { + it("sharpens with default settings", async () => { + const res = await postTool({}); + expect(res.statusCode).toBe(200); + const result = JSON.parse(res.body); + expect(result.downloadUrl).toBeDefined(); + expect(result.processedSize).toBeGreaterThan(0); + }); + + it("sharpens with custom sigma", async () => { + const res = await postTool({ method: "adaptive", sigma: 3.0 }); + expect(res.statusCode).toBe(200); + const result = JSON.parse(res.body); + expect(result.downloadUrl).toBeDefined(); + }); + + it("sharpens with all adaptive parameters", async () => { + const res = await postTool({ + method: "adaptive", + sigma: 2.0, + m1: 2.0, + m2: 5.0, + x1: 3.0, + y2: 15, + y3: 25, + }); + expect(res.statusCode).toBe(200); + const result = JSON.parse(res.body); + expect(result.downloadUrl).toBeDefined(); + }); +}); + +// ── Unsharp mask method ─────────────────────────────────────────── +describe("Unsharp mask sharpening", () => { + it("sharpens with unsharp-mask method", async () => { + const res = await postTool({ method: "unsharp-mask" }); + expect(res.statusCode).toBe(200); + const result = JSON.parse(res.body); + expect(result.downloadUrl).toBeDefined(); + }); + + it("sharpens with custom amount and radius", async () => { + const res = await postTool({ + method: "unsharp-mask", + amount: 200, + radius: 2.5, + threshold: 10, + }); + expect(res.statusCode).toBe(200); + const result = JSON.parse(res.body); + expect(result.downloadUrl).toBeDefined(); + }); +}); + +// ── High-pass method ────────────────────────────────────────────── +describe("High-pass sharpening", () => { + it("sharpens with high-pass method", async () => { + const res = await postTool({ method: "high-pass" }); + expect(res.statusCode).toBe(200); + const result = JSON.parse(res.body); + expect(result.downloadUrl).toBeDefined(); + }); + + it("sharpens with custom strength and kernel size 5", async () => { + const res = await postTool({ method: "high-pass", strength: 80, kernelSize: 5 }); + expect(res.statusCode).toBe(200); + const result = JSON.parse(res.body); + expect(result.downloadUrl).toBeDefined(); + }); +}); + +// ── Denoise option ──────────────────────────────────────────────── +describe("Denoise option", () => { + it("applies light denoise", async () => { + const res = await postTool({ denoise: "light" }); + expect(res.statusCode).toBe(200); + }); + + it("applies medium denoise", async () => { + const res = await postTool({ denoise: "medium" }); + expect(res.statusCode).toBe(200); + }); + + it("applies strong denoise", async () => { + const res = await postTool({ denoise: "strong" }); + expect(res.statusCode).toBe(200); + }); +}); + +// ── Output verification ────────────────────────────────────────── +describe("Output verification", () => { + it("output differs from input (sharpening changes data)", async () => { + const res = await postTool({ method: "adaptive", sigma: 5.0 }); + expect(res.statusCode).toBe(200); + const result = JSON.parse(res.body); + + const dlRes = await app.inject({ + method: "GET", + url: result.downloadUrl, + headers: { authorization: `Bearer ${adminToken}` }, + }); + expect(dlRes.statusCode).toBe(200); + + // Sharpened output should not be identical to input + expect(Buffer.compare(dlRes.rawPayload, PNG)).not.toBe(0); + }); + + it("preserves image dimensions", async () => { + const res = await postTool({ method: "unsharp-mask", amount: 300 }); + expect(res.statusCode).toBe(200); + const result = JSON.parse(res.body); + + const dlRes = await app.inject({ + method: "GET", + url: result.downloadUrl, + headers: { authorization: `Bearer ${adminToken}` }, + }); + const meta = await sharp(dlRes.rawPayload).metadata(); + expect(meta.width).toBe(200); + expect(meta.height).toBe(150); + }); +}); + +// ── Format support ──────────────────────────────────────────────── +describe("Multiple input formats", () => { + it("processes JPEG input", async () => { + const res = await postTool({ method: "adaptive" }, JPG, "test.jpg", "image/jpeg"); + expect(res.statusCode).toBe(200); + const result = JSON.parse(res.body); + expect(result.downloadUrl).toBeDefined(); + }); + + it("processes WebP input", async () => { + const res = await postTool({ method: "adaptive" }, WEBP, "test.webp", "image/webp"); + expect(res.statusCode).toBe(200); + const result = JSON.parse(res.body); + expect(result.downloadUrl).toBeDefined(); + }); +}); + +// ── Error handling ──────────────────────────────────────────────── +describe("Error handling", () => { + it("returns 400 when no file is provided", async () => { + const { body: payload, contentType } = createMultipartPayload([ + { name: "settings", content: JSON.stringify({ method: "adaptive" }) }, + ]); + const res = await app.inject({ + method: "POST", + url: "/api/v1/tools/sharpening", + payload, + headers: { + "content-type": contentType, + authorization: `Bearer ${adminToken}`, + }, + }); + expect(res.statusCode).toBe(400); + }); + + it("returns 400 for invalid method", async () => { + const res = await postTool({ method: "gaussian" }); + expect(res.statusCode).toBe(400); + }); + + it("returns 400 for sigma out of range (too low)", async () => { + const res = await postTool({ sigma: 0.1 }); + expect(res.statusCode).toBe(400); + }); + + it("returns 400 for sigma out of range (too high)", async () => { + const res = await postTool({ sigma: 50 }); + expect(res.statusCode).toBe(400); + }); + + it("returns 400 for invalid kernelSize", async () => { + const res = await postTool({ method: "high-pass", kernelSize: 7 }); + expect(res.statusCode).toBe(400); + }); + + it("returns 400 for invalid denoise value", async () => { + const res = await postTool({ denoise: "extreme" }); + expect(res.statusCode).toBe(400); + }); +}); diff --git a/tests/integration/split.test.ts b/tests/integration/split.test.ts new file mode 100644 index 00000000..18df36e4 --- /dev/null +++ b/tests/integration/split.test.ts @@ -0,0 +1,253 @@ +/** + * Integration tests for the split tool (/api/v1/tools/split). + * + * The split tool divides an image into a grid of tiles and returns a ZIP. + * It uses reply.hijack() to stream the ZIP directly, so responses are + * raw binary rather than JSON. + */ + +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import AdmZip from "adm-zip"; +import sharp from "sharp"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { buildTestApp, createMultipartPayload, loginAsAdmin, type TestApp } from "./test-server.js"; + +const FIXTURES = join(__dirname, "..", "fixtures"); +const PNG = readFileSync(join(FIXTURES, "test-200x150.png")); + +let testApp: TestApp; +let app: TestApp["app"]; +let adminToken: string; + +beforeAll(async () => { + testApp = await buildTestApp(); + app = testApp.app; + adminToken = await loginAsAdmin(app); +}, 30_000); + +afterAll(async () => { + await testApp.cleanup(); +}, 10_000); + +describe("Split", () => { + it("splits a 200x150 image into a 2x2 grid", async () => { + const { body, contentType } = createMultipartPayload([ + { name: "file", filename: "test.png", contentType: "image/png", content: PNG }, + { + name: "settings", + content: JSON.stringify({ columns: 2, rows: 2 }), + }, + ]); + + const res = await app.inject({ + method: "POST", + url: "/api/v1/tools/split", + headers: { + authorization: `Bearer ${adminToken}`, + "content-type": contentType, + }, + body, + }); + + expect(res.statusCode).toBe(200); + expect(res.headers["content-type"]).toBe("application/zip"); + + // Parse the ZIP and verify 4 tiles + const zip = new AdmZip(res.rawPayload); + const entries = zip.getEntries(); + expect(entries.length).toBe(4); + + // Verify tile naming convention: test_r1_c1.png, test_r1_c2.png, etc. + const names = entries.map((e) => e.entryName).sort(); + expect(names).toEqual(["test_r1_c1.png", "test_r1_c2.png", "test_r2_c1.png", "test_r2_c2.png"]); + + // Verify each tile has correct dimensions + // 200/2 = 100 wide, 150/2 = 75 tall + for (const entry of entries) { + const meta = await sharp(entry.getData()).metadata(); + expect(meta.width).toBe(100); + expect(meta.height).toBe(75); + } + }); + + it("splits into a 3x3 grid with remainder tiles", async () => { + const { body, contentType } = createMultipartPayload([ + { name: "file", filename: "img.png", contentType: "image/png", content: PNG }, + { + name: "settings", + content: JSON.stringify({ columns: 3, rows: 3 }), + }, + ]); + + const res = await app.inject({ + method: "POST", + url: "/api/v1/tools/split", + headers: { + authorization: `Bearer ${adminToken}`, + "content-type": contentType, + }, + body, + }); + + expect(res.statusCode).toBe(200); + + const zip = new AdmZip(res.rawPayload); + const entries = zip.getEntries(); + expect(entries.length).toBe(9); + + // Bottom-right tile gets the remainder pixels + // 200/3 = floor 66, last col: 200 - 2*66 = 68 + // 150/3 = floor 50, last row: 150 - 2*50 = 50 + const bottomRight = entries.find((e) => e.entryName === "img_r3_c3.png"); + expect(bottomRight).toBeDefined(); + const meta = await sharp(bottomRight!.getData()).metadata(); + expect(meta.width).toBe(200 - 2 * 66); // 68 + expect(meta.height).toBe(150 - 2 * 50); // 50 + }); + + it("splits using fixed tile dimensions", async () => { + const { body, contentType } = createMultipartPayload([ + { name: "file", filename: "test.png", contentType: "image/png", content: PNG }, + { + name: "settings", + content: JSON.stringify({ tileWidth: 100, tileHeight: 75 }), + }, + ]); + + const res = await app.inject({ + method: "POST", + url: "/api/v1/tools/split", + headers: { + authorization: `Bearer ${adminToken}`, + "content-type": contentType, + }, + body, + }); + + expect(res.statusCode).toBe(200); + + const zip = new AdmZip(res.rawPayload); + const entries = zip.getEntries(); + // 200/100 = 2 cols, 150/75 = 2 rows = 4 tiles + expect(entries.length).toBe(4); + }); + + it("converts tile format to webp", async () => { + const { body, contentType } = createMultipartPayload([ + { name: "file", filename: "test.png", contentType: "image/png", content: PNG }, + { + name: "settings", + content: JSON.stringify({ columns: 2, rows: 1, outputFormat: "webp" }), + }, + ]); + + const res = await app.inject({ + method: "POST", + url: "/api/v1/tools/split", + headers: { + authorization: `Bearer ${adminToken}`, + "content-type": contentType, + }, + body, + }); + + expect(res.statusCode).toBe(200); + + const zip = new AdmZip(res.rawPayload); + const entries = zip.getEntries(); + expect(entries.length).toBe(2); + + // Verify filenames have .webp extension + for (const entry of entries) { + expect(entry.entryName).toMatch(/\.webp$/); + } + + // Verify actual format is webp + const meta = await sharp(entries[0].getData()).metadata(); + expect(meta.format).toBe("webp"); + }); + + it("uses default settings (3x3 grid) when none provided", async () => { + const { body, contentType } = createMultipartPayload([ + { name: "file", filename: "test.png", contentType: "image/png", content: PNG }, + { name: "settings", content: JSON.stringify({}) }, + ]); + + const res = await app.inject({ + method: "POST", + url: "/api/v1/tools/split", + headers: { + authorization: `Bearer ${adminToken}`, + "content-type": contentType, + }, + body, + }); + + expect(res.statusCode).toBe(200); + + const zip = new AdmZip(res.rawPayload); + // Default is 3 columns x 3 rows = 9 tiles + expect(zip.getEntries().length).toBe(9); + }); + + // ── Validation ────────────────────────────────────────────────────── + + it("rejects requests without a file", async () => { + const { body, contentType } = createMultipartPayload([ + { name: "settings", content: JSON.stringify({ columns: 2, rows: 2 }) }, + ]); + + const res = await app.inject({ + method: "POST", + url: "/api/v1/tools/split", + headers: { + authorization: `Bearer ${adminToken}`, + "content-type": contentType, + }, + body, + }); + + expect(res.statusCode).toBe(400); + const result = JSON.parse(res.body); + expect(result.error).toMatch(/no image/i); + }); + + it("rejects columns exceeding max (100)", async () => { + const { body, contentType } = createMultipartPayload([ + { name: "file", filename: "test.png", contentType: "image/png", content: PNG }, + { + name: "settings", + content: JSON.stringify({ columns: 101, rows: 2 }), + }, + ]); + + const res = await app.inject({ + method: "POST", + url: "/api/v1/tools/split", + headers: { + authorization: `Bearer ${adminToken}`, + "content-type": contentType, + }, + body, + }); + + expect(res.statusCode).toBe(400); + }); + + it("rejects unauthenticated requests", async () => { + const { body, contentType } = createMultipartPayload([ + { name: "file", filename: "test.png", contentType: "image/png", content: PNG }, + { name: "settings", content: JSON.stringify({ columns: 2, rows: 2 }) }, + ]); + + const res = await app.inject({ + method: "POST", + url: "/api/v1/tools/split", + headers: { "content-type": contentType }, + body, + }); + + expect(res.statusCode).toBe(401); + }); +}); diff --git a/tests/integration/stitch.test.ts b/tests/integration/stitch.test.ts new file mode 100644 index 00000000..e5516010 --- /dev/null +++ b/tests/integration/stitch.test.ts @@ -0,0 +1,435 @@ +/** + * Integration tests for the stitch tool (/api/v1/tools/stitch). + * + * Stitch joins multiple images horizontally, vertically, or in a grid. + * It requires at least 2 images and accepts them via any file field name + * (type === "file"). + */ + +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import sharp from "sharp"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { buildTestApp, createMultipartPayload, loginAsAdmin, type TestApp } from "./test-server.js"; + +const FIXTURES = join(__dirname, "..", "fixtures"); +const PNG = readFileSync(join(FIXTURES, "test-200x150.png")); +const JPG = readFileSync(join(FIXTURES, "test-100x100.jpg")); + +let testApp: TestApp; +let app: TestApp["app"]; +let adminToken: string; + +beforeAll(async () => { + testApp = await buildTestApp(); + app = testApp.app; + adminToken = await loginAsAdmin(app); +}, 30_000); + +afterAll(async () => { + await testApp.cleanup(); +}, 10_000); + +describe("Stitch", () => { + it("stitches two images horizontally with fit mode", async () => { + const { body, contentType } = createMultipartPayload([ + { name: "file1", filename: "a.png", contentType: "image/png", content: PNG }, + { name: "file2", filename: "b.jpg", contentType: "image/jpeg", content: JPG }, + { + name: "settings", + content: JSON.stringify({ direction: "horizontal", resizeMode: "fit" }), + }, + ]); + + const res = await app.inject({ + method: "POST", + url: "/api/v1/tools/stitch", + headers: { + authorization: `Bearer ${adminToken}`, + "content-type": contentType, + }, + body, + }); + + expect(res.statusCode).toBe(200); + const result = JSON.parse(res.body); + expect(result.downloadUrl).toBeDefined(); + expect(result.processedSize).toBeGreaterThan(0); + + // Download and verify dimensions: fit mode scales to smallest height + const dlRes = await app.inject({ + method: "GET", + url: result.downloadUrl, + headers: { authorization: `Bearer ${adminToken}` }, + }); + const meta = await sharp(dlRes.rawPayload).metadata(); + // Both scaled to min height (100). PNG 200x150 -> 133x100, JPG stays 100x100. + // Total width = 133 + 100 = 233, height = 100 + expect(meta.height).toBe(100); + expect(meta.width).toBeGreaterThan(200); // combined width + }); + + it("stitches two images vertically with fit mode", async () => { + const { body, contentType } = createMultipartPayload([ + { name: "file1", filename: "a.png", contentType: "image/png", content: PNG }, + { name: "file2", filename: "b.jpg", contentType: "image/jpeg", content: JPG }, + { + name: "settings", + content: JSON.stringify({ direction: "vertical", resizeMode: "fit" }), + }, + ]); + + const res = await app.inject({ + method: "POST", + url: "/api/v1/tools/stitch", + headers: { + authorization: `Bearer ${adminToken}`, + "content-type": contentType, + }, + body, + }); + + expect(res.statusCode).toBe(200); + const result = JSON.parse(res.body); + + const dlRes = await app.inject({ + method: "GET", + url: result.downloadUrl, + headers: { authorization: `Bearer ${adminToken}` }, + }); + const meta = await sharp(dlRes.rawPayload).metadata(); + // fit mode scales to smallest width (100). PNG 200x150 -> 100x75, JPG stays 100x100. + // Total height = 75 + 100 = 175, width = 100 + expect(meta.width).toBe(100); + expect(meta.height).toBe(175); + }); + + it("stitches with original resize mode (no resizing)", async () => { + const { body, contentType } = createMultipartPayload([ + { name: "file1", filename: "a.png", contentType: "image/png", content: PNG }, + { name: "file2", filename: "b.jpg", contentType: "image/jpeg", content: JPG }, + { + name: "settings", + content: JSON.stringify({ direction: "horizontal", resizeMode: "original" }), + }, + ]); + + const res = await app.inject({ + method: "POST", + url: "/api/v1/tools/stitch", + headers: { + authorization: `Bearer ${adminToken}`, + "content-type": contentType, + }, + body, + }); + + expect(res.statusCode).toBe(200); + const result = JSON.parse(res.body); + + const dlRes = await app.inject({ + method: "GET", + url: result.downloadUrl, + headers: { authorization: `Bearer ${adminToken}` }, + }); + const meta = await sharp(dlRes.rawPayload).metadata(); + // Original sizes: 200 + 100 = 300 wide, max height = 150 + expect(meta.width).toBe(300); + expect(meta.height).toBe(150); + }); + + it("stitches in grid mode", async () => { + const { body, contentType } = createMultipartPayload([ + { name: "f1", filename: "a.png", contentType: "image/png", content: PNG }, + { name: "f2", filename: "b.jpg", contentType: "image/jpeg", content: JPG }, + { name: "f3", filename: "c.png", contentType: "image/png", content: PNG }, + { name: "f4", filename: "d.jpg", contentType: "image/jpeg", content: JPG }, + { + name: "settings", + content: JSON.stringify({ + direction: "grid", + gridColumns: 2, + resizeMode: "stretch", + }), + }, + ]); + + const res = await app.inject({ + method: "POST", + url: "/api/v1/tools/stitch", + headers: { + authorization: `Bearer ${adminToken}`, + "content-type": contentType, + }, + body, + }); + + expect(res.statusCode).toBe(200); + const result = JSON.parse(res.body); + expect(result.downloadUrl).toBeDefined(); + expect(result.processedSize).toBeGreaterThan(0); + }); + + it("applies gap between images", async () => { + const gap = 20; + const { body, contentType } = createMultipartPayload([ + { name: "f1", filename: "a.jpg", contentType: "image/jpeg", content: JPG }, + { name: "f2", filename: "b.jpg", contentType: "image/jpeg", content: JPG }, + { + name: "settings", + content: JSON.stringify({ + direction: "horizontal", + resizeMode: "original", + gap, + }), + }, + ]); + + const res = await app.inject({ + method: "POST", + url: "/api/v1/tools/stitch", + headers: { + authorization: `Bearer ${adminToken}`, + "content-type": contentType, + }, + body, + }); + + expect(res.statusCode).toBe(200); + const result = JSON.parse(res.body); + + const dlRes = await app.inject({ + method: "GET", + url: result.downloadUrl, + headers: { authorization: `Bearer ${adminToken}` }, + }); + const meta = await sharp(dlRes.rawPayload).metadata(); + // Two 100px wide images + 20px gap = 220 wide + expect(meta.width).toBe(100 + 100 + gap); + expect(meta.height).toBe(100); + }); + + it("applies border around the stitched result", async () => { + const border = 15; + const { body, contentType } = createMultipartPayload([ + { name: "f1", filename: "a.jpg", contentType: "image/jpeg", content: JPG }, + { name: "f2", filename: "b.jpg", contentType: "image/jpeg", content: JPG }, + { + name: "settings", + content: JSON.stringify({ + direction: "horizontal", + resizeMode: "original", + border, + }), + }, + ]); + + const res = await app.inject({ + method: "POST", + url: "/api/v1/tools/stitch", + headers: { + authorization: `Bearer ${adminToken}`, + "content-type": contentType, + }, + body, + }); + + expect(res.statusCode).toBe(200); + const result = JSON.parse(res.body); + + const dlRes = await app.inject({ + method: "GET", + url: result.downloadUrl, + headers: { authorization: `Bearer ${adminToken}` }, + }); + const meta = await sharp(dlRes.rawPayload).metadata(); + // Two 100px images + 15px border on each side = 230 wide, 130 tall + expect(meta.width).toBe(100 + 100 + border * 2); + expect(meta.height).toBe(100 + border * 2); + }); + + it("outputs in webp format", async () => { + const { body, contentType } = createMultipartPayload([ + { name: "f1", filename: "a.png", contentType: "image/png", content: PNG }, + { name: "f2", filename: "b.jpg", contentType: "image/jpeg", content: JPG }, + { + name: "settings", + content: JSON.stringify({ + direction: "horizontal", + format: "webp", + quality: 85, + }), + }, + ]); + + const res = await app.inject({ + method: "POST", + url: "/api/v1/tools/stitch", + headers: { + authorization: `Bearer ${adminToken}`, + "content-type": contentType, + }, + body, + }); + + expect(res.statusCode).toBe(200); + const result = JSON.parse(res.body); + + const dlRes = await app.inject({ + method: "GET", + url: result.downloadUrl, + headers: { authorization: `Bearer ${adminToken}` }, + }); + const meta = await sharp(dlRes.rawPayload).metadata(); + expect(meta.format).toBe("webp"); + }); + + it("applies corner radius to the final result", async () => { + const { body, contentType } = createMultipartPayload([ + { name: "f1", filename: "a.png", contentType: "image/png", content: PNG }, + { name: "f2", filename: "b.png", contentType: "image/png", content: PNG }, + { + name: "settings", + content: JSON.stringify({ + direction: "horizontal", + cornerRadius: 30, + format: "png", + }), + }, + ]); + + const res = await app.inject({ + method: "POST", + url: "/api/v1/tools/stitch", + headers: { + authorization: `Bearer ${adminToken}`, + "content-type": contentType, + }, + body, + }); + + expect(res.statusCode).toBe(200); + const result = JSON.parse(res.body); + + const dlRes = await app.inject({ + method: "GET", + url: result.downloadUrl, + headers: { authorization: `Bearer ${adminToken}` }, + }); + const meta = await sharp(dlRes.rawPayload).metadata(); + // Corner radius forces PNG with alpha + expect(meta.channels).toBe(4); + }); + + // ── Validation ────────────────────────────────────────────────────── + + it("rejects requests with fewer than 2 images", async () => { + const { body, contentType } = createMultipartPayload([ + { name: "file", filename: "solo.png", contentType: "image/png", content: PNG }, + { + name: "settings", + content: JSON.stringify({ direction: "horizontal" }), + }, + ]); + + const res = await app.inject({ + method: "POST", + url: "/api/v1/tools/stitch", + headers: { + authorization: `Bearer ${adminToken}`, + "content-type": contentType, + }, + body, + }); + + expect(res.statusCode).toBe(400); + const result = JSON.parse(res.body); + expect(result.error).toMatch(/at least 2/i); + }); + + it("rejects requests with no images", async () => { + const { body, contentType } = createMultipartPayload([ + { + name: "settings", + content: JSON.stringify({ direction: "horizontal" }), + }, + ]); + + const res = await app.inject({ + method: "POST", + url: "/api/v1/tools/stitch", + headers: { + authorization: `Bearer ${adminToken}`, + "content-type": contentType, + }, + body, + }); + + expect(res.statusCode).toBe(400); + }); + + it("rejects invalid direction", async () => { + const { body, contentType } = createMultipartPayload([ + { name: "f1", filename: "a.png", contentType: "image/png", content: PNG }, + { name: "f2", filename: "b.jpg", contentType: "image/jpeg", content: JPG }, + { + name: "settings", + content: JSON.stringify({ direction: "diagonal" }), + }, + ]); + + const res = await app.inject({ + method: "POST", + url: "/api/v1/tools/stitch", + headers: { + authorization: `Bearer ${adminToken}`, + "content-type": contentType, + }, + body, + }); + + expect(res.statusCode).toBe(400); + const result = JSON.parse(res.body); + expect(result.error).toMatch(/invalid settings/i); + }); + + it("rejects invalid backgroundColor (non-hex)", async () => { + const { body, contentType } = createMultipartPayload([ + { name: "f1", filename: "a.png", contentType: "image/png", content: PNG }, + { name: "f2", filename: "b.jpg", contentType: "image/jpeg", content: JPG }, + { + name: "settings", + content: JSON.stringify({ backgroundColor: "red" }), + }, + ]); + + const res = await app.inject({ + method: "POST", + url: "/api/v1/tools/stitch", + headers: { + authorization: `Bearer ${adminToken}`, + "content-type": contentType, + }, + body, + }); + + expect(res.statusCode).toBe(400); + }); + + it("rejects unauthenticated requests", async () => { + const { body, contentType } = createMultipartPayload([ + { name: "f1", filename: "a.png", contentType: "image/png", content: PNG }, + { name: "f2", filename: "b.jpg", contentType: "image/jpeg", content: JPG }, + { name: "settings", content: JSON.stringify({}) }, + ]); + + const res = await app.inject({ + method: "POST", + url: "/api/v1/tools/stitch", + headers: { "content-type": contentType }, + body, + }); + + expect(res.statusCode).toBe(401); + }); +}); diff --git a/tests/integration/strip-metadata.test.ts b/tests/integration/strip-metadata.test.ts new file mode 100644 index 00000000..3ed488ac --- /dev/null +++ b/tests/integration/strip-metadata.test.ts @@ -0,0 +1,258 @@ +/** + * Integration tests for the strip-metadata tool. + * + * Tests EXIF stripping, GPS stripping, ICC profile stripping, XMP stripping, + * and the stripAll flag. Uses test-with-exif.jpg fixture and verifies that + * metadata is actually removed from the output. + */ + +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import sharp from "sharp"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { buildTestApp, createMultipartPayload, loginAsAdmin, type TestApp } from "./test-server.js"; + +const FIXTURES = join(__dirname, "..", "fixtures"); +const EXIF_JPG = readFileSync(join(FIXTURES, "test-with-exif.jpg")); +const PNG = readFileSync(join(FIXTURES, "test-200x150.png")); + +let testApp: TestApp; +let app: TestApp["app"]; +let adminToken: string; + +beforeAll(async () => { + testApp = await buildTestApp(); + app = testApp.app; + adminToken = await loginAsAdmin(app); +}, 30_000); + +afterAll(async () => { + await testApp.cleanup(); +}, 10_000); + +function makePayload( + settings: Record, + buffer: Buffer = EXIF_JPG, + filename = "test-with-exif.jpg", + contentType = "image/jpeg", +) { + return createMultipartPayload([ + { name: "file", filename, contentType, content: buffer }, + { name: "settings", content: JSON.stringify(settings) }, + ]); +} + +async function postTool( + settings: Record, + buffer?: Buffer, + filename?: string, + ct?: string, +) { + const { body: payload, contentType } = makePayload(settings, buffer, filename, ct); + return app.inject({ + method: "POST", + url: "/api/v1/tools/strip-metadata", + payload, + headers: { + "content-type": contentType, + authorization: `Bearer ${adminToken}`, + }, + }); +} + +// ── Verify fixture has metadata ─────────────────────────────────── +describe("Fixture verification", () => { + it("test-with-exif.jpg has EXIF data", async () => { + const meta = await sharp(EXIF_JPG).metadata(); + expect(meta.exif).toBeDefined(); + expect(meta.exif!.length).toBeGreaterThan(0); + }); +}); + +// ── Strip all metadata ──────────────────────────────────────────── +describe("Strip all metadata", () => { + it("strips all metadata with stripAll=true (default)", async () => { + const res = await postTool({ stripAll: true }); + expect(res.statusCode).toBe(200); + const result = JSON.parse(res.body); + expect(result.downloadUrl).toBeDefined(); + expect(result.processedSize).toBeGreaterThan(0); + + // Download and verify EXIF is gone + const dlRes = await app.inject({ + method: "GET", + url: result.downloadUrl, + headers: { authorization: `Bearer ${adminToken}` }, + }); + expect(dlRes.statusCode).toBe(200); + + const meta = await sharp(dlRes.rawPayload).metadata(); + // After stripping all, EXIF/ICC/XMP should be absent or empty + expect(!meta.exif || meta.exif.length === 0).toBe(true); + expect(!meta.icc || meta.icc.length === 0).toBe(true); + expect(!meta.xmp || meta.xmp.length === 0).toBe(true); + }); + + it("strips with default settings (stripAll defaults to true)", async () => { + const res = await postTool({}); + expect(res.statusCode).toBe(200); + const result = JSON.parse(res.body); + expect(result.downloadUrl).toBeDefined(); + }); +}); + +// ── Strip individual categories ─────────────────────────────────── +describe("Selective stripping", () => { + it("strips only EXIF data", async () => { + const res = await postTool({ stripAll: false, stripExif: true }); + expect(res.statusCode).toBe(200); + const result = JSON.parse(res.body); + expect(result.downloadUrl).toBeDefined(); + expect(result.processedSize).toBeGreaterThan(0); + }); + + it("strips only GPS data", async () => { + const res = await postTool({ stripAll: false, stripGps: true }); + expect(res.statusCode).toBe(200); + const result = JSON.parse(res.body); + expect(result.downloadUrl).toBeDefined(); + }); + + it("strips only ICC profile", async () => { + const res = await postTool({ stripAll: false, stripIcc: true }); + expect(res.statusCode).toBe(200); + const result = JSON.parse(res.body); + expect(result.downloadUrl).toBeDefined(); + }); + + it("strips only XMP data", async () => { + const res = await postTool({ stripAll: false, stripXmp: true }); + expect(res.statusCode).toBe(200); + const result = JSON.parse(res.body); + expect(result.downloadUrl).toBeDefined(); + }); + + it("strips EXIF + GPS but keeps ICC", async () => { + const res = await postTool({ + stripAll: false, + stripExif: true, + stripGps: true, + stripIcc: false, + }); + expect(res.statusCode).toBe(200); + const result = JSON.parse(res.body); + expect(result.downloadUrl).toBeDefined(); + }); +}); + +// ── Inspect endpoint ────────────────────────────────────────────── +describe("Inspect endpoint", () => { + it("returns parsed metadata for image with EXIF", async () => { + const { body: payload, contentType } = createMultipartPayload([ + { + name: "file", + filename: "test-with-exif.jpg", + contentType: "image/jpeg", + content: EXIF_JPG, + }, + ]); + const res = await app.inject({ + method: "POST", + url: "/api/v1/tools/strip-metadata/inspect", + payload, + headers: { + "content-type": contentType, + authorization: `Bearer ${adminToken}`, + }, + }); + expect(res.statusCode).toBe(200); + const result = JSON.parse(res.body); + expect(result.filename).toBeDefined(); + expect(result.fileSize).toBeGreaterThan(0); + // Should have parsed exif or at least have the filename + expect(result.filename).toBe("test-with-exif.jpg"); + }); + + it("returns 400 when no file is provided to inspect", async () => { + const { body: payload, contentType } = createMultipartPayload([ + { name: "other", content: "nothing" }, + ]); + const res = await app.inject({ + method: "POST", + url: "/api/v1/tools/strip-metadata/inspect", + payload, + headers: { + "content-type": contentType, + authorization: `Bearer ${adminToken}`, + }, + }); + expect(res.statusCode).toBe(400); + }); +}); + +// ── Format preservation ─────────────────────────────────────────── +describe("Format handling", () => { + it("preserves JPEG format after stripping", async () => { + const res = await postTool({ stripAll: true }); + expect(res.statusCode).toBe(200); + const result = JSON.parse(res.body); + + const dlRes = await app.inject({ + method: "GET", + url: result.downloadUrl, + headers: { authorization: `Bearer ${adminToken}` }, + }); + const meta = await sharp(dlRes.rawPayload).metadata(); + expect(meta.format).toBe("jpeg"); + }); + + it("processes PNG input", async () => { + const res = await postTool({ stripAll: true }, PNG, "test.png", "image/png"); + expect(res.statusCode).toBe(200); + const result = JSON.parse(res.body); + expect(result.downloadUrl).toBeDefined(); + + const dlRes = await app.inject({ + method: "GET", + url: result.downloadUrl, + headers: { authorization: `Bearer ${adminToken}` }, + }); + const meta = await sharp(dlRes.rawPayload).metadata(); + expect(meta.format).toBe("png"); + }); + + it("preserves image dimensions after stripping", async () => { + const originalMeta = await sharp(EXIF_JPG).metadata(); + const res = await postTool({ stripAll: true }); + expect(res.statusCode).toBe(200); + const result = JSON.parse(res.body); + + const dlRes = await app.inject({ + method: "GET", + url: result.downloadUrl, + headers: { authorization: `Bearer ${adminToken}` }, + }); + const meta = await sharp(dlRes.rawPayload).metadata(); + expect(meta.width).toBe(originalMeta.width); + expect(meta.height).toBe(originalMeta.height); + }); +}); + +// ── Error handling ──────────────────────────────────────────────── +describe("Error handling", () => { + it("returns 400 when no file is provided", async () => { + const { body: payload, contentType } = createMultipartPayload([ + { name: "settings", content: JSON.stringify({ stripAll: true }) }, + ]); + const res = await app.inject({ + method: "POST", + url: "/api/v1/tools/strip-metadata", + payload, + headers: { + "content-type": contentType, + authorization: `Bearer ${adminToken}`, + }, + }); + expect(res.statusCode).toBe(400); + }); +}); diff --git a/tests/integration/svg-to-raster.test.ts b/tests/integration/svg-to-raster.test.ts new file mode 100644 index 00000000..df32d95f --- /dev/null +++ b/tests/integration/svg-to-raster.test.ts @@ -0,0 +1,240 @@ +/** + * Integration tests for the svg-to-raster tool. + * + * Renders SVG files to raster images (PNG, JPG, WebP, etc.). Custom route + * that validates SVG input separately from the standard image validation. + */ + +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import sharp from "sharp"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { buildTestApp, createMultipartPayload, loginAsAdmin, type TestApp } from "./test-server.js"; + +const FIXTURES = join(__dirname, "..", "fixtures"); +const SVG = readFileSync(join(FIXTURES, "test-100x100.svg")); +const PNG = readFileSync(join(FIXTURES, "test-200x150.png")); + +let testApp: TestApp; +let app: TestApp["app"]; +let adminToken: string; + +beforeAll(async () => { + testApp = await buildTestApp(); + app = testApp.app; + adminToken = await loginAsAdmin(app); +}, 30_000); + +afterAll(async () => { + await testApp.cleanup(); +}, 10_000); + +describe("svg-to-raster", () => { + it("converts SVG to PNG with default settings", async () => { + const { body, contentType } = createMultipartPayload([ + { name: "file", filename: "test.svg", contentType: "image/svg+xml", content: SVG }, + { name: "settings", content: JSON.stringify({}) }, + ]); + + const res = await app.inject({ + method: "POST", + url: "/api/v1/tools/svg-to-raster", + headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType }, + body, + }); + + expect(res.statusCode).toBe(200); + const json = JSON.parse(res.body); + expect(json.downloadUrl).toBeDefined(); + expect(json.jobId).toBeDefined(); + expect(json.processedSize).toBeGreaterThan(0); + }); + + it("output is a valid raster image", async () => { + const { body, contentType } = createMultipartPayload([ + { name: "file", filename: "test.svg", contentType: "image/svg+xml", content: SVG }, + { name: "settings", content: JSON.stringify({}) }, + ]); + + const res = await app.inject({ + method: "POST", + url: "/api/v1/tools/svg-to-raster", + headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType }, + body, + }); + + expect(res.statusCode).toBe(200); + const json = JSON.parse(res.body); + + const dlRes = await app.inject({ + method: "GET", + url: json.downloadUrl, + headers: { authorization: `Bearer ${adminToken}` }, + }); + expect(dlRes.statusCode).toBe(200); + const meta = await sharp(Buffer.from(dlRes.rawPayload)).metadata(); + expect(meta.format).toBe("png"); + expect(meta.width).toBeGreaterThan(0); + expect(meta.height).toBeGreaterThan(0); + }); + + it("respects custom width override", async () => { + const { body, contentType } = createMultipartPayload([ + { name: "file", filename: "test.svg", contentType: "image/svg+xml", content: SVG }, + { name: "settings", content: JSON.stringify({ width: 200 }) }, + ]); + + const res = await app.inject({ + method: "POST", + url: "/api/v1/tools/svg-to-raster", + headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType }, + body, + }); + + expect(res.statusCode).toBe(200); + const json = JSON.parse(res.body); + const dlRes = await app.inject({ + method: "GET", + url: json.downloadUrl, + headers: { authorization: `Bearer ${adminToken}` }, + }); + const meta = await sharp(Buffer.from(dlRes.rawPayload)).metadata(); + expect(meta.width).toBeLessThanOrEqual(200); + }); + + it("respects custom height override", async () => { + const { body, contentType } = createMultipartPayload([ + { name: "file", filename: "test.svg", contentType: "image/svg+xml", content: SVG }, + { name: "settings", content: JSON.stringify({ height: 50 }) }, + ]); + + const res = await app.inject({ + method: "POST", + url: "/api/v1/tools/svg-to-raster", + headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType }, + body, + }); + + expect(res.statusCode).toBe(200); + const json = JSON.parse(res.body); + const dlRes = await app.inject({ + method: "GET", + url: json.downloadUrl, + headers: { authorization: `Bearer ${adminToken}` }, + }); + const meta = await sharp(Buffer.from(dlRes.rawPayload)).metadata(); + expect(meta.height).toBeLessThanOrEqual(50); + }); + + it.each([ + "png", + "jpg", + "webp", + ] as const)("converts to output format: %s", async (outputFormat) => { + const { body, contentType } = createMultipartPayload([ + { name: "file", filename: "test.svg", contentType: "image/svg+xml", content: SVG }, + { name: "settings", content: JSON.stringify({ outputFormat }) }, + ]); + + const res = await app.inject({ + method: "POST", + url: "/api/v1/tools/svg-to-raster", + headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType }, + body, + }); + + expect(res.statusCode).toBe(200); + const json = JSON.parse(res.body); + const dlRes = await app.inject({ + method: "GET", + url: json.downloadUrl, + headers: { authorization: `Bearer ${adminToken}` }, + }); + const meta = await sharp(Buffer.from(dlRes.rawPayload)).metadata(); + const expectedFormat = outputFormat === "jpg" ? "jpeg" : outputFormat; + expect(meta.format).toBe(expectedFormat); + }); + + it("respects DPI setting", async () => { + const { body, contentType } = createMultipartPayload([ + { name: "file", filename: "test.svg", contentType: "image/svg+xml", content: SVG }, + { name: "settings", content: JSON.stringify({ dpi: 72 }) }, + ]); + + const res = await app.inject({ + method: "POST", + url: "/api/v1/tools/svg-to-raster", + headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType }, + body, + }); + + expect(res.statusCode).toBe(200); + }); + + it("applies background color", async () => { + const { body, contentType } = createMultipartPayload([ + { name: "file", filename: "test.svg", contentType: "image/svg+xml", content: SVG }, + { name: "settings", content: JSON.stringify({ backgroundColor: "#FF0000" }) }, + ]); + + const res = await app.inject({ + method: "POST", + url: "/api/v1/tools/svg-to-raster", + headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType }, + body, + }); + + expect(res.statusCode).toBe(200); + }); + + it("rejects request without a file", async () => { + const { body, contentType } = createMultipartPayload([ + { name: "settings", content: JSON.stringify({}) }, + ]); + + const res = await app.inject({ + method: "POST", + url: "/api/v1/tools/svg-to-raster", + headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType }, + body, + }); + + expect(res.statusCode).toBe(400); + const json = JSON.parse(res.body); + expect(json.error).toContain("No SVG file"); + }); + + it("rejects non-SVG file", async () => { + const { body, contentType } = createMultipartPayload([ + { name: "file", filename: "test.png", contentType: "image/png", content: PNG }, + { name: "settings", content: JSON.stringify({}) }, + ]); + + const res = await app.inject({ + method: "POST", + url: "/api/v1/tools/svg-to-raster", + headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType }, + body, + }); + + expect(res.statusCode).toBe(400); + const json = JSON.parse(res.body); + expect(json.error).toContain("not a valid SVG"); + }); + + it("rejects invalid settings", async () => { + const { body, contentType } = createMultipartPayload([ + { name: "file", filename: "test.svg", contentType: "image/svg+xml", content: SVG }, + { name: "settings", content: JSON.stringify({ dpi: 5 }) }, + ]); + + const res = await app.inject({ + method: "POST", + url: "/api/v1/tools/svg-to-raster", + headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType }, + body, + }); + + expect(res.statusCode).toBe(400); + }); +}); diff --git a/tests/integration/text-overlay.test.ts b/tests/integration/text-overlay.test.ts new file mode 100644 index 00000000..a35ac4d8 --- /dev/null +++ b/tests/integration/text-overlay.test.ts @@ -0,0 +1,216 @@ +/** + * Integration tests for the text-overlay tool. + * + * Adds styled text onto an image with position, color, background box, + * and shadow options. + */ + +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { buildTestApp, createMultipartPayload, loginAsAdmin, type TestApp } from "./test-server.js"; + +const FIXTURES = join(__dirname, "..", "fixtures"); +const PNG = readFileSync(join(FIXTURES, "test-200x150.png")); + +let testApp: TestApp; +let app: TestApp["app"]; +let adminToken: string; + +beforeAll(async () => { + testApp = await buildTestApp(); + app = testApp.app; + adminToken = await loginAsAdmin(app); +}, 30_000); + +afterAll(async () => { + await testApp.cleanup(); +}, 10_000); + +describe("text-overlay", () => { + it("adds text overlay with default settings", async () => { + const { body, contentType } = createMultipartPayload([ + { name: "file", filename: "test.png", contentType: "image/png", content: PNG }, + { name: "settings", content: JSON.stringify({ text: "Hello World" }) }, + ]); + + const res = await app.inject({ + method: "POST", + url: "/api/v1/tools/text-overlay", + headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType }, + body, + }); + + expect(res.statusCode).toBe(200); + const json = JSON.parse(res.body); + expect(json.downloadUrl).toBeDefined(); + expect(json.jobId).toBeDefined(); + expect(json.processedSize).toBeGreaterThan(0); + }); + + it("output differs from input", async () => { + const { body, contentType } = createMultipartPayload([ + { name: "file", filename: "test.png", contentType: "image/png", content: PNG }, + { name: "settings", content: JSON.stringify({ text: "Overlay" }) }, + ]); + + const res = await app.inject({ + method: "POST", + url: "/api/v1/tools/text-overlay", + headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType }, + body, + }); + + expect(res.statusCode).toBe(200); + const json = JSON.parse(res.body); + const dlRes = await app.inject({ + method: "GET", + url: json.downloadUrl, + headers: { authorization: `Bearer ${adminToken}` }, + }); + expect(dlRes.statusCode).toBe(200); + expect(Buffer.from(dlRes.rawPayload).equals(PNG)).toBe(false); + }); + + it.each(["top", "center", "bottom"] as const)("supports position: %s", async (position) => { + const { body, contentType } = createMultipartPayload([ + { name: "file", filename: "test.png", contentType: "image/png", content: PNG }, + { name: "settings", content: JSON.stringify({ text: "Pos", position }) }, + ]); + + const res = await app.inject({ + method: "POST", + url: "/api/v1/tools/text-overlay", + headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType }, + body, + }); + + expect(res.statusCode).toBe(200); + }); + + it("applies custom color and font size", async () => { + const { body, contentType } = createMultipartPayload([ + { name: "file", filename: "test.png", contentType: "image/png", content: PNG }, + { + name: "settings", + content: JSON.stringify({ text: "Styled", color: "#FF0000", fontSize: 24 }), + }, + ]); + + const res = await app.inject({ + method: "POST", + url: "/api/v1/tools/text-overlay", + headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType }, + body, + }); + + expect(res.statusCode).toBe(200); + }); + + it("enables background box", async () => { + const { body, contentType } = createMultipartPayload([ + { name: "file", filename: "test.png", contentType: "image/png", content: PNG }, + { + name: "settings", + content: JSON.stringify({ + text: "With BG", + backgroundBox: true, + backgroundColor: "#333333", + }), + }, + ]); + + const res = await app.inject({ + method: "POST", + url: "/api/v1/tools/text-overlay", + headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType }, + body, + }); + + expect(res.statusCode).toBe(200); + }); + + it("disables shadow", async () => { + const { body, contentType } = createMultipartPayload([ + { name: "file", filename: "test.png", contentType: "image/png", content: PNG }, + { + name: "settings", + content: JSON.stringify({ text: "No Shadow", shadow: false }), + }, + ]); + + const res = await app.inject({ + method: "POST", + url: "/api/v1/tools/text-overlay", + headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType }, + body, + }); + + expect(res.statusCode).toBe(200); + }); + + it("rejects request without a file", async () => { + const { body, contentType } = createMultipartPayload([ + { name: "settings", content: JSON.stringify({ text: "NoFile" }) }, + ]); + + const res = await app.inject({ + method: "POST", + url: "/api/v1/tools/text-overlay", + headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType }, + body, + }); + + expect(res.statusCode).toBe(400); + }); + + it("rejects request without text", async () => { + const { body, contentType } = createMultipartPayload([ + { name: "file", filename: "test.png", contentType: "image/png", content: PNG }, + { name: "settings", content: JSON.stringify({}) }, + ]); + + const res = await app.inject({ + method: "POST", + url: "/api/v1/tools/text-overlay", + headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType }, + body, + }); + + expect(res.statusCode).toBe(400); + const json = JSON.parse(res.body); + expect(json.error).toContain("Invalid settings"); + }); + + it("rejects invalid color format", async () => { + const { body, contentType } = createMultipartPayload([ + { name: "file", filename: "test.png", contentType: "image/png", content: PNG }, + { name: "settings", content: JSON.stringify({ text: "Bad", color: "blue" }) }, + ]); + + const res = await app.inject({ + method: "POST", + url: "/api/v1/tools/text-overlay", + headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType }, + body, + }); + + expect(res.statusCode).toBe(400); + }); + + it("rejects fontSize out of range", async () => { + const { body, contentType } = createMultipartPayload([ + { name: "file", filename: "test.png", contentType: "image/png", content: PNG }, + { name: "settings", content: JSON.stringify({ text: "Big", fontSize: 999 }) }, + ]); + + const res = await app.inject({ + method: "POST", + url: "/api/v1/tools/text-overlay", + headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType }, + body, + }); + + expect(res.statusCode).toBe(400); + }); +}); diff --git a/tests/integration/vectorize.test.ts b/tests/integration/vectorize.test.ts new file mode 100644 index 00000000..1a90b038 --- /dev/null +++ b/tests/integration/vectorize.test.ts @@ -0,0 +1,243 @@ +/** + * Integration tests for the vectorize tool. + * + * Converts raster images to SVG via potrace (black-and-white) or + * @neplex/vectorizer (color). Custom route with settings for colorMode, + * threshold, path mode, and more. + */ + +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { buildTestApp, createMultipartPayload, loginAsAdmin, type TestApp } from "./test-server.js"; + +const FIXTURES = join(__dirname, "..", "fixtures"); +const PNG = readFileSync(join(FIXTURES, "test-200x150.png")); +const SMALL_PNG = readFileSync(join(FIXTURES, "test-1x1.png")); + +let testApp: TestApp; +let app: TestApp["app"]; +let adminToken: string; + +beforeAll(async () => { + testApp = await buildTestApp(); + app = testApp.app; + adminToken = await loginAsAdmin(app); +}, 30_000); + +afterAll(async () => { + await testApp.cleanup(); +}, 10_000); + +describe("vectorize", () => { + it("vectorizes a PNG with default settings (bw mode)", async () => { + const { body, contentType } = createMultipartPayload([ + { name: "file", filename: "test.png", contentType: "image/png", content: PNG }, + { name: "settings", content: JSON.stringify({}) }, + ]); + + const res = await app.inject({ + method: "POST", + url: "/api/v1/tools/vectorize", + headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType }, + body, + }); + + expect(res.statusCode).toBe(200); + const json = JSON.parse(res.body); + expect(json.downloadUrl).toBeDefined(); + expect(json.jobId).toBeDefined(); + expect(json.processedSize).toBeGreaterThan(0); + }); + + it("output is valid SVG", async () => { + const { body, contentType } = createMultipartPayload([ + { name: "file", filename: "test.png", contentType: "image/png", content: PNG }, + { name: "settings", content: JSON.stringify({}) }, + ]); + + const res = await app.inject({ + method: "POST", + url: "/api/v1/tools/vectorize", + headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType }, + body, + }); + + expect(res.statusCode).toBe(200); + const json = JSON.parse(res.body); + + const dlRes = await app.inject({ + method: "GET", + url: json.downloadUrl, + headers: { authorization: `Bearer ${adminToken}` }, + }); + expect(dlRes.statusCode).toBe(200); + const svgContent = dlRes.rawPayload.toString("utf-8"); + expect(svgContent).toContain(""); + }); + + it("vectorizes in color mode", async () => { + const { body, contentType } = createMultipartPayload([ + { name: "file", filename: "test.png", contentType: "image/png", content: PNG }, + { name: "settings", content: JSON.stringify({ colorMode: "color" }) }, + ]); + + const res = await app.inject({ + method: "POST", + url: "/api/v1/tools/vectorize", + headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType }, + body, + }); + + expect(res.statusCode).toBe(200); + const json = JSON.parse(res.body); + const dlRes = await app.inject({ + method: "GET", + url: json.downloadUrl, + headers: { authorization: `Bearer ${adminToken}` }, + }); + const svgContent = dlRes.rawPayload.toString("utf-8"); + expect(svgContent).toContain(" { + const { body, contentType } = createMultipartPayload([ + { name: "file", filename: "test.png", contentType: "image/png", content: PNG }, + { name: "settings", content: JSON.stringify({ colorMode: "bw", threshold: 200 }) }, + ]); + + const res = await app.inject({ + method: "POST", + url: "/api/v1/tools/vectorize", + headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType }, + body, + }); + + expect(res.statusCode).toBe(200); + }); + + it("supports invert option", async () => { + const { body, contentType } = createMultipartPayload([ + { name: "file", filename: "test.png", contentType: "image/png", content: PNG }, + { name: "settings", content: JSON.stringify({ invert: true }) }, + ]); + + const res = await app.inject({ + method: "POST", + url: "/api/v1/tools/vectorize", + headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType }, + body, + }); + + expect(res.statusCode).toBe(200); + }); + + it.each(["none", "polygon", "spline"] as const)("supports pathMode: %s", async (pathMode) => { + const { body, contentType } = createMultipartPayload([ + { name: "file", filename: "test.png", contentType: "image/png", content: PNG }, + { name: "settings", content: JSON.stringify({ pathMode }) }, + ]); + + const res = await app.inject({ + method: "POST", + url: "/api/v1/tools/vectorize", + headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType }, + body, + }); + + expect(res.statusCode).toBe(200); + }); + + it("respects color mode settings (colorPrecision, layerDifference)", async () => { + const { body, contentType } = createMultipartPayload([ + { name: "file", filename: "test.png", contentType: "image/png", content: PNG }, + { + name: "settings", + content: JSON.stringify({ + colorMode: "color", + colorPrecision: 4, + layerDifference: 16, + filterSpeckle: 8, + cornerThreshold: 90, + }), + }, + ]); + + const res = await app.inject({ + method: "POST", + url: "/api/v1/tools/vectorize", + headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType }, + body, + }); + + expect(res.statusCode).toBe(200); + }); + + it("download URL filename ends with .svg", async () => { + const { body, contentType } = createMultipartPayload([ + { name: "file", filename: "myimage.png", contentType: "image/png", content: PNG }, + { name: "settings", content: JSON.stringify({}) }, + ]); + + const res = await app.inject({ + method: "POST", + url: "/api/v1/tools/vectorize", + headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType }, + body, + }); + + expect(res.statusCode).toBe(200); + const json = JSON.parse(res.body); + expect(json.downloadUrl).toMatch(/\.svg$/); + }); + + it("rejects request without a file", async () => { + const { body, contentType } = createMultipartPayload([ + { name: "settings", content: JSON.stringify({}) }, + ]); + + const res = await app.inject({ + method: "POST", + url: "/api/v1/tools/vectorize", + headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType }, + body, + }); + + expect(res.statusCode).toBe(400); + const json = JSON.parse(res.body); + expect(json.error).toContain("No image file"); + }); + + it("rejects invalid settings JSON", async () => { + const { body, contentType } = createMultipartPayload([ + { name: "file", filename: "test.png", contentType: "image/png", content: PNG }, + { name: "settings", content: "not-json" }, + ]); + + const res = await app.inject({ + method: "POST", + url: "/api/v1/tools/vectorize", + headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType }, + body, + }); + + expect(res.statusCode).toBe(400); + }); + + it("rejects threshold out of range", async () => { + const { body, contentType } = createMultipartPayload([ + { name: "file", filename: "test.png", contentType: "image/png", content: PNG }, + { name: "settings", content: JSON.stringify({ threshold: 999 }) }, + ]); + + const res = await app.inject({ + method: "POST", + url: "/api/v1/tools/vectorize", + headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType }, + body, + }); + + expect(res.statusCode).toBe(400); + }); +}); diff --git a/tests/integration/watermark-image.test.ts b/tests/integration/watermark-image.test.ts new file mode 100644 index 00000000..ece842b9 --- /dev/null +++ b/tests/integration/watermark-image.test.ts @@ -0,0 +1,189 @@ +/** + * Integration tests for the watermark-image tool. + * + * Overlays one image onto another as a watermark. Uses a custom route + * (not createToolRoute) with two file fields: "file" for the main image + * and "watermark" for the overlay image. + */ + +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { buildTestApp, createMultipartPayload, loginAsAdmin, type TestApp } from "./test-server.js"; + +const FIXTURES = join(__dirname, "..", "fixtures"); +const PNG = readFileSync(join(FIXTURES, "test-200x150.png")); +const SMALL_PNG = readFileSync(join(FIXTURES, "test-1x1.png")); +const JPG = readFileSync(join(FIXTURES, "test-100x100.jpg")); + +let testApp: TestApp; +let app: TestApp["app"]; +let adminToken: string; + +beforeAll(async () => { + testApp = await buildTestApp(); + app = testApp.app; + adminToken = await loginAsAdmin(app); +}, 30_000); + +afterAll(async () => { + await testApp.cleanup(); +}, 10_000); + +describe("watermark-image", () => { + it("overlays a watermark image with default settings", async () => { + const { body, contentType } = createMultipartPayload([ + { name: "file", filename: "main.png", contentType: "image/png", content: PNG }, + { name: "watermark", filename: "wm.png", contentType: "image/png", content: SMALL_PNG }, + { name: "settings", content: JSON.stringify({}) }, + ]); + + const res = await app.inject({ + method: "POST", + url: "/api/v1/tools/watermark-image", + headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType }, + body, + }); + + expect(res.statusCode).toBe(200); + const json = JSON.parse(res.body); + expect(json.downloadUrl).toBeDefined(); + expect(json.jobId).toBeDefined(); + expect(json.processedSize).toBeGreaterThan(0); + }); + + it("output differs from the main input", async () => { + const { body, contentType } = createMultipartPayload([ + { name: "file", filename: "main.png", contentType: "image/png", content: PNG }, + { name: "watermark", filename: "wm.jpg", contentType: "image/jpeg", content: JPG }, + { name: "settings", content: JSON.stringify({ scale: 50 }) }, + ]); + + const res = await app.inject({ + method: "POST", + url: "/api/v1/tools/watermark-image", + headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType }, + body, + }); + + expect(res.statusCode).toBe(200); + const json = JSON.parse(res.body); + const dlRes = await app.inject({ + method: "GET", + url: json.downloadUrl, + headers: { authorization: `Bearer ${adminToken}` }, + }); + expect(dlRes.statusCode).toBe(200); + expect(Buffer.from(dlRes.rawPayload).equals(PNG)).toBe(false); + }); + + it.each([ + "center", + "top-left", + "top-right", + "bottom-left", + "bottom-right", + ] as const)("supports position: %s", async (position) => { + const { body, contentType } = createMultipartPayload([ + { name: "file", filename: "main.png", contentType: "image/png", content: PNG }, + { name: "watermark", filename: "wm.png", contentType: "image/png", content: SMALL_PNG }, + { name: "settings", content: JSON.stringify({ position }) }, + ]); + + const res = await app.inject({ + method: "POST", + url: "/api/v1/tools/watermark-image", + headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType }, + body, + }); + + expect(res.statusCode).toBe(200); + }); + + it("respects custom opacity and scale", async () => { + const { body, contentType } = createMultipartPayload([ + { name: "file", filename: "main.png", contentType: "image/png", content: PNG }, + { name: "watermark", filename: "wm.png", contentType: "image/png", content: SMALL_PNG }, + { name: "settings", content: JSON.stringify({ opacity: 30, scale: 10 }) }, + ]); + + const res = await app.inject({ + method: "POST", + url: "/api/v1/tools/watermark-image", + headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType }, + body, + }); + + expect(res.statusCode).toBe(200); + }); + + it("rejects request without main image", async () => { + const { body, contentType } = createMultipartPayload([ + { name: "watermark", filename: "wm.png", contentType: "image/png", content: SMALL_PNG }, + { name: "settings", content: JSON.stringify({}) }, + ]); + + const res = await app.inject({ + method: "POST", + url: "/api/v1/tools/watermark-image", + headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType }, + body, + }); + + expect(res.statusCode).toBe(400); + const json = JSON.parse(res.body); + expect(json.error).toContain("No main image"); + }); + + it("rejects request without watermark image", async () => { + const { body, contentType } = createMultipartPayload([ + { name: "file", filename: "main.png", contentType: "image/png", content: PNG }, + { name: "settings", content: JSON.stringify({}) }, + ]); + + const res = await app.inject({ + method: "POST", + url: "/api/v1/tools/watermark-image", + headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType }, + body, + }); + + expect(res.statusCode).toBe(400); + const json = JSON.parse(res.body); + expect(json.error).toContain("No watermark image"); + }); + + it("rejects invalid settings JSON", async () => { + const { body, contentType } = createMultipartPayload([ + { name: "file", filename: "main.png", contentType: "image/png", content: PNG }, + { name: "watermark", filename: "wm.png", contentType: "image/png", content: SMALL_PNG }, + { name: "settings", content: "not-json" }, + ]); + + const res = await app.inject({ + method: "POST", + url: "/api/v1/tools/watermark-image", + headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType }, + body, + }); + + expect(res.statusCode).toBe(400); + }); + + it("rejects opacity out of range", async () => { + const { body, contentType } = createMultipartPayload([ + { name: "file", filename: "main.png", contentType: "image/png", content: PNG }, + { name: "watermark", filename: "wm.png", contentType: "image/png", content: SMALL_PNG }, + { name: "settings", content: JSON.stringify({ opacity: 150 }) }, + ]); + + const res = await app.inject({ + method: "POST", + url: "/api/v1/tools/watermark-image", + headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType }, + body, + }); + + expect(res.statusCode).toBe(400); + }); +}); diff --git a/tests/integration/watermark-text.test.ts b/tests/integration/watermark-text.test.ts new file mode 100644 index 00000000..e4ee09e8 --- /dev/null +++ b/tests/integration/watermark-text.test.ts @@ -0,0 +1,203 @@ +/** + * Integration tests for the watermark-text tool. + * + * Adds an SVG text watermark onto an image. Tests verify position options, + * opacity, font size, tiled mode, and input validation. + */ + +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { buildTestApp, createMultipartPayload, loginAsAdmin, type TestApp } from "./test-server.js"; + +const FIXTURES = join(__dirname, "..", "fixtures"); +const PNG = readFileSync(join(FIXTURES, "test-200x150.png")); + +let testApp: TestApp; +let app: TestApp["app"]; +let adminToken: string; + +beforeAll(async () => { + testApp = await buildTestApp(); + app = testApp.app; + adminToken = await loginAsAdmin(app); +}, 30_000); + +afterAll(async () => { + await testApp.cleanup(); +}, 10_000); + +describe("watermark-text", () => { + it("adds a text watermark with default settings", async () => { + const { body, contentType } = createMultipartPayload([ + { name: "file", filename: "test.png", contentType: "image/png", content: PNG }, + { name: "settings", content: JSON.stringify({ text: "Sample" }) }, + ]); + + const res = await app.inject({ + method: "POST", + url: "/api/v1/tools/watermark-text", + headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType }, + body, + }); + + expect(res.statusCode).toBe(200); + const json = JSON.parse(res.body); + expect(json.downloadUrl).toBeDefined(); + expect(json.jobId).toBeDefined(); + expect(json.processedSize).toBeGreaterThan(0); + }); + + it("output differs from input", async () => { + const { body, contentType } = createMultipartPayload([ + { name: "file", filename: "test.png", contentType: "image/png", content: PNG }, + { name: "settings", content: JSON.stringify({ text: "Watermark" }) }, + ]); + + const res = await app.inject({ + method: "POST", + url: "/api/v1/tools/watermark-text", + headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType }, + body, + }); + + expect(res.statusCode).toBe(200); + const json = JSON.parse(res.body); + // Download the result and verify it differs from the original + const dlRes = await app.inject({ + method: "GET", + url: json.downloadUrl, + headers: { authorization: `Bearer ${adminToken}` }, + }); + expect(dlRes.statusCode).toBe(200); + expect(Buffer.from(dlRes.rawPayload).equals(PNG)).toBe(false); + }); + + it.each([ + "center", + "top-left", + "top-right", + "bottom-left", + "bottom-right", + "tiled", + ] as const)("supports position: %s", async (position) => { + const { body, contentType } = createMultipartPayload([ + { name: "file", filename: "test.png", contentType: "image/png", content: PNG }, + { name: "settings", content: JSON.stringify({ text: "Pos", position }) }, + ]); + + const res = await app.inject({ + method: "POST", + url: "/api/v1/tools/watermark-text", + headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType }, + body, + }); + + expect(res.statusCode).toBe(200); + const json = JSON.parse(res.body); + expect(json.downloadUrl).toBeDefined(); + }); + + it("respects custom opacity and font size", async () => { + const { body, contentType } = createMultipartPayload([ + { name: "file", filename: "test.png", contentType: "image/png", content: PNG }, + { + name: "settings", + content: JSON.stringify({ text: "Custom", opacity: 80, fontSize: 24 }), + }, + ]); + + const res = await app.inject({ + method: "POST", + url: "/api/v1/tools/watermark-text", + headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType }, + body, + }); + + expect(res.statusCode).toBe(200); + }); + + it("respects custom color and rotation", async () => { + const { body, contentType } = createMultipartPayload([ + { name: "file", filename: "test.png", contentType: "image/png", content: PNG }, + { + name: "settings", + content: JSON.stringify({ text: "Red", color: "#FF0000", rotation: 45 }), + }, + ]); + + const res = await app.inject({ + method: "POST", + url: "/api/v1/tools/watermark-text", + headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType }, + body, + }); + + expect(res.statusCode).toBe(200); + }); + + it("rejects request without a file", async () => { + const { body, contentType } = createMultipartPayload([ + { name: "settings", content: JSON.stringify({ text: "NoFile" }) }, + ]); + + const res = await app.inject({ + method: "POST", + url: "/api/v1/tools/watermark-text", + headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType }, + body, + }); + + expect(res.statusCode).toBe(400); + }); + + it("rejects request without text", async () => { + const { body, contentType } = createMultipartPayload([ + { name: "file", filename: "test.png", contentType: "image/png", content: PNG }, + { name: "settings", content: JSON.stringify({}) }, + ]); + + const res = await app.inject({ + method: "POST", + url: "/api/v1/tools/watermark-text", + headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType }, + body, + }); + + expect(res.statusCode).toBe(400); + const json = JSON.parse(res.body); + expect(json.error).toContain("Invalid settings"); + }); + + it("rejects invalid color format", async () => { + const { body, contentType } = createMultipartPayload([ + { name: "file", filename: "test.png", contentType: "image/png", content: PNG }, + { name: "settings", content: JSON.stringify({ text: "Bad", color: "red" }) }, + ]); + + const res = await app.inject({ + method: "POST", + url: "/api/v1/tools/watermark-text", + headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType }, + body, + }); + + expect(res.statusCode).toBe(400); + }); + + it("rejects opacity out of range", async () => { + const { body, contentType } = createMultipartPayload([ + { name: "file", filename: "test.png", contentType: "image/png", content: PNG }, + { name: "settings", content: JSON.stringify({ text: "Bad", opacity: 200 }) }, + ]); + + const res = await app.inject({ + method: "POST", + url: "/api/v1/tools/watermark-text", + headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType }, + body, + }); + + expect(res.statusCode).toBe(400); + }); +}); diff --git a/tests/unit/ai/bridge.test.ts b/tests/unit/ai/bridge.test.ts new file mode 100644 index 00000000..fc64f984 --- /dev/null +++ b/tests/unit/ai/bridge.test.ts @@ -0,0 +1,407 @@ +import { type ChildProcess, spawn } from "node:child_process"; +import { EventEmitter } from "node:events"; +import { Readable, Writable } from "node:stream"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +// Mock child_process.spawn before importing the bridge module +vi.mock("node:child_process", () => ({ + spawn: vi.fn(), +})); + +// Mock sharp (required transitively by tool modules) +vi.mock("sharp", () => ({ + default: vi.fn(), +})); + +// Helper to create a fake ChildProcess with controllable streams +function createMockProcess(): { + process: ChildProcess; + stdin: Writable; + stdout: EventEmitter; + stderr: EventEmitter; + emitEvent: (event: string, ...args: unknown[]) => void; +} { + const stdin = new Writable({ + write(_chunk, _encoding, callback) { + callback(); + }, + }); + const stdout = new EventEmitter(); + const stderr = new EventEmitter(); + + const proc = new EventEmitter() as unknown as ChildProcess; + Object.assign(proc, { + stdin, + stdout, + stderr, + pid: 12345, + killed: false, + kill: vi.fn(() => { + (proc as { killed: boolean }).killed = true; + return true; + }), + }); + + return { + process: proc, + stdin, + stdout, + stderr, + emitEvent: (event: string, ...args: unknown[]) => proc.emit(event, ...args), + }; +} + +describe("bridge - parseStdoutJson", () => { + // parseStdoutJson is a pure function, safe to test without mocking spawn + let parseStdoutJson: (stdout: string) => unknown; + + beforeEach(async () => { + // Dynamic import to get a fresh module each time + const mod = await import("../../../packages/ai/src/bridge.js"); + parseStdoutJson = mod.parseStdoutJson; + }); + + it("extracts JSON object from clean stdout", () => { + const result = parseStdoutJson('{"success": true, "text": "hello"}'); + expect(result).toEqual({ success: true, text: "hello" }); + }); + + it("extracts JSON from stdout with leading progress lines", () => { + const stdout = [ + "Loading model...", + "Processing: 50%", + "Processing: 100%", + '{"success": true, "width": 800, "height": 600}', + ].join("\n"); + + const result = parseStdoutJson(stdout); + expect(result).toEqual({ success: true, width: 800, height: 600 }); + }); + + it("matches greedily from first brace to last brace", () => { + // The regex /\{[\s\S]*\}$/ is greedy: when multiple JSON objects appear + // on separate lines it captures from the FIRST '{' to the LAST '}'. + // This only works when the earlier lines don't contain braces. + const stdout = "some log line\n" + '{"success": true, "result": "final"}'; + + const result = parseStdoutJson(stdout); + expect(result).toEqual({ success: true, result: "final" }); + }); + + it("throws when multiple JSON objects produce invalid merged JSON", () => { + // The greedy regex merges two separate JSON lines into one invalid string + const stdout = [ + '{"progress": 50}', + "some log line", + '{"success": true, "result": "final"}', + ].join("\n"); + + // This demonstrates the greedy regex limitation + expect(() => parseStdoutJson(stdout)).toThrow(); + }); + + it("throws when stdout contains no JSON", () => { + expect(() => parseStdoutJson("just some text output")).toThrow( + "No JSON response from Python script", + ); + }); + + it("throws on empty stdout", () => { + expect(() => parseStdoutJson("")).toThrow("No JSON response from Python script"); + }); + + it("throws when JSON is malformed", () => { + expect(() => parseStdoutJson("{not valid json}")).toThrow(); + }); + + it("handles multiline JSON object", () => { + const stdout = `some progress line +{ + "success": true, + "data": { + "nested": "value" + } +}`; + const result = parseStdoutJson(stdout); + expect(result).toEqual({ success: true, data: { nested: "value" } }); + }); + + it("extracts JSON with special characters in string values", () => { + const result = parseStdoutJson('{"text": "hello\\nworld", "path": "/tmp/foo bar.png"}'); + expect(result).toEqual({ text: "hello\nworld", path: "/tmp/foo bar.png" }); + }); +}); + +describe("bridge - isGpuAvailable", () => { + let isGpuAvailable: () => boolean; + + beforeEach(async () => { + vi.resetModules(); + const mod = await import("../../../packages/ai/src/bridge.js"); + isGpuAvailable = mod.isGpuAvailable; + }); + + it("returns false by default (no dispatcher started)", () => { + // Without starting a dispatcher, GPU should default to false + expect(isGpuAvailable()).toBe(false); + }); +}); + +describe("bridge - shutdownDispatcher", () => { + let shutdownDispatcher: () => void; + + beforeEach(async () => { + vi.resetModules(); + const mod = await import("../../../packages/ai/src/bridge.js"); + shutdownDispatcher = mod.shutdownDispatcher; + }); + + it("does not throw when no dispatcher is running", () => { + expect(() => shutdownDispatcher()).not.toThrow(); + }); +}); + +describe("bridge - runPythonWithProgress (per-request fallback)", () => { + let runPythonWithProgress: typeof import("../../../packages/ai/src/bridge.js").runPythonWithProgress; + + beforeEach(async () => { + vi.resetModules(); + vi.mocked(spawn).mockReset(); + + const mod = await import("../../../packages/ai/src/bridge.js"); + runPythonWithProgress = mod.runPythonWithProgress; + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("resolves with stdout/stderr on successful exit (code 0)", async () => { + const mock = createMockProcess(); + vi.mocked(spawn).mockReturnValue(mock.process); + + const promise = runPythonWithProgress("test_script.py", ["arg1", "arg2"]); + + // Simulate Python output then exit + mock.stdout.emit("data", Buffer.from('{"success": true}\n')); + mock.emitEvent("close", 0, null); + + const result = await promise; + expect(result.stdout).toBe('{"success": true}'); + }); + + it("rejects with error message on non-zero exit code", async () => { + const mock = createMockProcess(); + vi.mocked(spawn).mockReturnValue(mock.process); + + const promise = runPythonWithProgress("test_script.py", []); + + mock.stderr.emit("data", Buffer.from("RuntimeError: model not found\n")); + mock.emitEvent("close", 1, null); + + await expect(promise).rejects.toThrow("RuntimeError: model not found"); + }); + + it("rejects with OOM message on exit code 137 (SIGKILL)", async () => { + const mock = createMockProcess(); + vi.mocked(spawn).mockReturnValue(mock.process); + + const promise = runPythonWithProgress("test_script.py", []); + + mock.emitEvent("close", 137, "SIGKILL"); + + await expect(promise).rejects.toThrow("Process killed (out of memory)"); + }); + + it("rejects with segfault message on exit code 139 (SIGSEGV)", async () => { + const mock = createMockProcess(); + vi.mocked(spawn).mockReturnValue(mock.process); + + const promise = runPythonWithProgress("test_script.py", []); + + mock.emitEvent("close", 139, "SIGSEGV"); + + await expect(promise).rejects.toThrow("Process crashed (segmentation fault)"); + }); + + it("rejects with timeout error when process exceeds timeout", async () => { + vi.useFakeTimers(); + const mock = createMockProcess(); + vi.mocked(spawn).mockReturnValue(mock.process); + + const promise = runPythonWithProgress("test_script.py", [], { + timeout: 1000, + }); + + // Advance past the timeout + vi.advanceTimersByTime(1500); + + // The timeout kills the process, then close event fires + mock.emitEvent("close", null, "SIGTERM"); + + await expect(promise).rejects.toThrow("Python script timed out"); + vi.useRealTimers(); + }); + + it("invokes onProgress callback for JSON progress lines on stderr", async () => { + const mock = createMockProcess(); + vi.mocked(spawn).mockReturnValue(mock.process); + const progressUpdates: Array<{ percent: number; stage: string }> = []; + + const promise = runPythonWithProgress("test_script.py", [], { + onProgress: (percent, stage) => { + progressUpdates.push({ percent, stage }); + }, + }); + + // Emit progress lines on stderr (Python convention) + mock.stderr.emit("data", Buffer.from('{"progress": 25, "stage": "Loading model"}\n')); + mock.stderr.emit("data", Buffer.from('{"progress": 75, "stage": "Processing"}\n')); + + // Emit result and close + mock.stdout.emit("data", Buffer.from('{"success": true}\n')); + mock.emitEvent("close", 0, null); + + await promise; + expect(progressUpdates).toEqual([ + { percent: 25, stage: "Loading model" }, + { percent: 75, stage: "Processing" }, + ]); + }); + + it("rejects when spawn emits ENOENT error and fallback also fails", async () => { + // runPythonWithProgress does 3 spawn calls in the ENOENT path: + // 1. dispatcher spawn (startDispatcher) + // 2. per-request venv python spawn + // 3. per-request fallback python3 spawn + const mockDispatcher = createMockProcess(); + const mockVenv = createMockProcess(); + const mockFallback = createMockProcess(); + let callCount = 0; + + vi.mocked(spawn).mockImplementation(() => { + callCount++; + if (callCount === 1) return mockDispatcher.process; + if (callCount === 2) return mockVenv.process; + return mockFallback.process; + }); + + const promise = runPythonWithProgress("test_script.py", []); + + // Dispatcher spawn fails with ENOENT (marks dispatcherFailed = true) + const dispatcherError = new Error("spawn ENOENT") as NodeJS.ErrnoException; + dispatcherError.code = "ENOENT"; + mockDispatcher.emitEvent("error", dispatcherError); + + // Allow microtask queue to process the dispatcher failure and start per-request + await new Promise((r) => setTimeout(r, 10)); + + // Per-request venv python fails with ENOENT + const venvError = new Error("spawn ENOENT") as NodeJS.ErrnoException; + venvError.code = "ENOENT"; + mockVenv.emitEvent("error", venvError); + + // Allow microtask for fallback spawn + await new Promise((r) => setTimeout(r, 10)); + + // Fallback python3 also fails + const fallbackError = new Error("spawn ENOENT") as NodeJS.ErrnoException; + fallbackError.code = "ENOENT"; + mockFallback.emitEvent("error", fallbackError); + + await expect(promise).rejects.toThrow(); + }); + + it("extracts error from JSON stderr when Python writes structured errors", async () => { + const mock = createMockProcess(); + vi.mocked(spawn).mockReturnValue(mock.process); + + const promise = runPythonWithProgress("test_script.py", []); + + // Python writes a structured error to stdout + mock.stdout.emit("data", Buffer.from('{"error": "CUDA out of memory"}\n')); + mock.emitEvent("close", 1, null); + + await expect(promise).rejects.toThrow(); + }); + + it("handles stderr output that is not JSON (regular log lines)", async () => { + const mock = createMockProcess(); + vi.mocked(spawn).mockReturnValue(mock.process); + + const promise = runPythonWithProgress("test_script.py", []); + + // Regular log line, not JSON + mock.stderr.emit("data", Buffer.from("Warning: deprecated API\n")); + mock.stdout.emit("data", Buffer.from('{"success": true}\n')); + mock.emitEvent("close", 0, null); + + const result = await promise; + // Stderr contains the warning line + expect(result.stderr).toContain("Warning: deprecated API"); + }); + + it("handles chunked stdout data arriving in multiple events", async () => { + const mock = createMockProcess(); + vi.mocked(spawn).mockReturnValue(mock.process); + + const promise = runPythonWithProgress("test_script.py", []); + + // JSON arrives in two chunks + mock.stdout.emit("data", Buffer.from('{"success":')); + mock.stdout.emit("data", Buffer.from(" true}\n")); + mock.emitEvent("close", 0, null); + + const result = await promise; + expect(result.stdout).toBe('{"success": true}'); + }); + + it("extracts last line from Python traceback on non-zero exit", async () => { + const mock = createMockProcess(); + vi.mocked(spawn).mockReturnValue(mock.process); + + const promise = runPythonWithProgress("test_script.py", []); + + const traceback = [ + "Traceback (most recent call last):", + ' File "script.py", line 10, in ', + ' raise ValueError("bad input")', + "ValueError: bad input", + ].join("\n"); + + mock.stderr.emit("data", Buffer.from(traceback + "\n")); + mock.emitEvent("close", 1, null); + + await expect(promise).rejects.toThrow("ValueError: bad input"); + }); + + it("passes script path and args to spawn correctly", async () => { + const mock = createMockProcess(); + vi.mocked(spawn).mockReturnValue(mock.process); + + const promise = runPythonWithProgress("remove_bg.py", ["/tmp/in.png", "/tmp/out.png"]); + + mock.stdout.emit("data", Buffer.from('{"success": true}\n')); + mock.emitEvent("close", 0, null); + + await promise; + + // spawn is called at least twice: once for dispatcher, once for per-request. + // The per-request call (last or second) includes the script path + user args. + expect(spawn).toHaveBeenCalled(); + const allCalls = vi.mocked(spawn).mock.calls; + // Find the per-request call that includes our user args + const perRequestCall = allCalls.find( + (call) => + Array.isArray(call[1]) && call[1].some((arg: string) => arg.includes("/tmp/in.png")), + ); + expect(perRequestCall).toBeDefined(); + expect(perRequestCall![1]).toEqual( + expect.arrayContaining([ + expect.stringContaining("remove_bg.py"), + "/tmp/in.png", + "/tmp/out.png", + ]), + ); + }); +}); diff --git a/tests/unit/ai/tools.test.ts b/tests/unit/ai/tools.test.ts new file mode 100644 index 00000000..e81eb95e --- /dev/null +++ b/tests/unit/ai/tools.test.ts @@ -0,0 +1,900 @@ +import { readFile, rm, unlink, writeFile } from "node:fs/promises"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +// Mock sharp before any imports that use it +vi.mock("sharp", () => { + const mockSharp = vi.fn(() => ({ + png: vi.fn().mockReturnThis(), + jpeg: vi.fn().mockReturnThis(), + toBuffer: vi.fn().mockResolvedValue(Buffer.from("mock-png-data")), + metadata: vi.fn().mockResolvedValue({ width: 800, height: 600 }), + })); + return { default: mockSharp }; +}); + +// Mock fs/promises +vi.mock("node:fs/promises", () => ({ + readFile: vi.fn().mockResolvedValue(Buffer.from("mock-output-data")), + writeFile: vi.fn().mockResolvedValue(undefined), + unlink: vi.fn().mockResolvedValue(undefined), + rm: vi.fn().mockResolvedValue(undefined), +})); + +// Mock the bridge module +vi.mock("../../../packages/ai/src/bridge.js", () => ({ + runPythonWithProgress: vi.fn(), + parseStdoutJson: vi.fn(), + isGpuAvailable: vi.fn(() => false), + shutdownDispatcher: vi.fn(), +})); + +// Import tool functions +import { removeBackground } from "../../../packages/ai/src/background-removal.js"; +// Import the mocked bridge functions +import { parseStdoutJson, runPythonWithProgress } from "../../../packages/ai/src/bridge.js"; +import { colorize } from "../../../packages/ai/src/colorization.js"; +import { blurFaces, detectFaces } from "../../../packages/ai/src/face-detection.js"; +import { enhanceFaces } from "../../../packages/ai/src/face-enhancement.js"; +import { detectFaceLandmarks } from "../../../packages/ai/src/face-landmarks.js"; +import { inpaint } from "../../../packages/ai/src/inpainting.js"; +import { noiseRemoval } from "../../../packages/ai/src/noise-removal.js"; +import { extractText } from "../../../packages/ai/src/ocr.js"; +import { removeRedEye } from "../../../packages/ai/src/red-eye-removal.js"; +import { restorePhoto } from "../../../packages/ai/src/restoration.js"; +import { upscale } from "../../../packages/ai/src/upscaling.js"; + +const FAKE_INPUT = Buffer.from("fake-image-data"); +const FAKE_OUTPUT_DIR = "/tmp/test-output"; + +beforeEach(() => { + vi.clearAllMocks(); + + // Re-establish fs mock defaults after clearAllMocks wipes them + vi.mocked(readFile).mockResolvedValue(Buffer.from("mock-output-data")); + vi.mocked(writeFile).mockResolvedValue(undefined); + vi.mocked(unlink).mockResolvedValue(undefined); + vi.mocked(rm).mockResolvedValue(undefined); + + // Default: runPythonWithProgress resolves with stdout containing success JSON + vi.mocked(runPythonWithProgress).mockResolvedValue({ + stdout: '{"success": true}', + stderr: "", + }); +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +// ── removeBackground ────────────────────────────────────────────────── + +describe("removeBackground", () => { + beforeEach(() => { + vi.mocked(parseStdoutJson).mockReturnValue({ success: true }); + }); + + it("calls runPythonWithProgress with remove_bg.py", async () => { + await removeBackground(FAKE_INPUT, FAKE_OUTPUT_DIR); + + expect(runPythonWithProgress).toHaveBeenCalledWith( + "remove_bg.py", + expect.arrayContaining([expect.stringContaining("rembg_in_")]), + expect.objectContaining({ timeout: expect.any(Number) }), + ); + }); + + it("passes options as JSON string argument", async () => { + await removeBackground(FAKE_INPUT, FAKE_OUTPUT_DIR, { model: "u2net" }); + + const args = vi.mocked(runPythonWithProgress).mock.calls[0][1]; + const optionsArg = args[2]; + expect(JSON.parse(optionsArg)).toEqual({ model: "u2net" }); + }); + + it("returns the output buffer on success", async () => { + const outputBuf = Buffer.from("result-image"); + vi.mocked(readFile).mockResolvedValueOnce(outputBuf); + + const result = await removeBackground(FAKE_INPUT, FAKE_OUTPUT_DIR); + expect(result).toBe(outputBuf); + }); + + it("throws when Python returns success: false", async () => { + vi.mocked(parseStdoutJson).mockReturnValue({ + success: false, + error: "Model not loaded", + }); + + await expect(removeBackground(FAKE_INPUT, FAKE_OUTPUT_DIR)).rejects.toThrow("Model not loaded"); + }); + + it("cleans up temp files on success", async () => { + await removeBackground(FAKE_INPUT, FAKE_OUTPUT_DIR); + + // unlink is called for both input and output temp files + expect(unlink).toHaveBeenCalledTimes(2); + }); + + it("cleans up temp files on failure", async () => { + vi.mocked(parseStdoutJson).mockReturnValue({ success: false, error: "fail" }); + + await expect(removeBackground(FAKE_INPUT, FAKE_OUTPUT_DIR)).rejects.toThrow(); + expect(unlink).toHaveBeenCalledTimes(2); + }); + + it("passes onProgress callback to runPythonWithProgress", async () => { + const onProgress = vi.fn(); + await removeBackground(FAKE_INPUT, FAKE_OUTPUT_DIR, {}, onProgress); + + expect(runPythonWithProgress).toHaveBeenCalledWith( + "remove_bg.py", + expect.any(Array), + expect.objectContaining({ onProgress }), + ); + }); + + it("uses higher timeout for birefnet models", async () => { + await removeBackground(FAKE_INPUT, FAKE_OUTPUT_DIR, { + model: "birefnet-general", + }); + + const options = vi.mocked(runPythonWithProgress).mock.calls[0][2]; + // birefnet base timeout is 600000 vs 300000 for others + expect(options.timeout).toBeGreaterThanOrEqual(600000); + }); +}); + +// ── upscale ─────────────────────────────────────────────────────────── + +describe("upscale", () => { + beforeEach(() => { + vi.mocked(parseStdoutJson).mockReturnValue({ + success: true, + width: 1600, + height: 1200, + method: "realesrgan", + format: "png", + }); + }); + + it("calls runPythonWithProgress with upscale.py", async () => { + await upscale(FAKE_INPUT, FAKE_OUTPUT_DIR); + + expect(runPythonWithProgress).toHaveBeenCalledWith( + "upscale.py", + expect.arrayContaining([ + expect.stringContaining("input_upscale.png"), + expect.stringContaining("output_upscale.png"), + ]), + expect.any(Object), + ); + }); + + it("returns UpscaleResult with dimensions and method", async () => { + const result = await upscale(FAKE_INPUT, FAKE_OUTPUT_DIR); + + expect(result).toEqual({ + buffer: expect.any(Buffer), + width: 1600, + height: 1200, + method: "realesrgan", + format: "png", + }); + }); + + it("passes scale and model options as JSON", async () => { + await upscale(FAKE_INPUT, FAKE_OUTPUT_DIR, { scale: 4, model: "realesrgan-x4plus" }); + + const args = vi.mocked(runPythonWithProgress).mock.calls[0][1]; + const optionsArg = args[2]; + expect(JSON.parse(optionsArg)).toEqual({ scale: 4, model: "realesrgan-x4plus" }); + }); + + it("throws when Python returns success: false", async () => { + vi.mocked(parseStdoutJson).mockReturnValue({ + success: false, + error: "CUDA error", + }); + + await expect(upscale(FAKE_INPUT, FAKE_OUTPUT_DIR)).rejects.toThrow("CUDA error"); + }); + + it("reads from output_path when Python provides alternate path", async () => { + vi.mocked(parseStdoutJson).mockReturnValue({ + success: true, + width: 1600, + height: 1200, + output_path: "/tmp/test-output/output_upscale.webp", + }); + + await upscale(FAKE_INPUT, FAKE_OUTPUT_DIR); + + expect(readFile).toHaveBeenCalledWith("/tmp/test-output/output_upscale.webp"); + }); + + it("defaults method and format when not provided by Python", async () => { + vi.mocked(parseStdoutJson).mockReturnValue({ + success: true, + width: 800, + height: 600, + }); + + const result = await upscale(FAKE_INPUT, FAKE_OUTPUT_DIR); + expect(result.method).toBe("unknown"); + expect(result.format).toBe("png"); + }); +}); + +// ── extractText (OCR) ───────────────────────────────────────────────── + +describe("extractText (OCR)", () => { + beforeEach(() => { + vi.mocked(parseStdoutJson).mockReturnValue({ + success: true, + text: "Hello World", + engine: "paddleocr", + }); + }); + + it("calls runPythonWithProgress with ocr.py", async () => { + await extractText(FAKE_INPUT, FAKE_OUTPUT_DIR); + + expect(runPythonWithProgress).toHaveBeenCalledWith( + "ocr.py", + expect.arrayContaining([expect.stringContaining("input_ocr.png")]), + expect.objectContaining({ timeout: expect.any(Number) }), + ); + }); + + it("returns OcrResult with text and engine", async () => { + const result = await extractText(FAKE_INPUT, FAKE_OUTPUT_DIR); + + expect(result).toEqual({ + text: "Hello World", + engine: "paddleocr", + }); + }); + + it("passes quality and language options", async () => { + await extractText(FAKE_INPUT, FAKE_OUTPUT_DIR, { + quality: "best", + language: "en", + }); + + const args = vi.mocked(runPythonWithProgress).mock.calls[0][1]; + const optionsArg = args[1]; + expect(JSON.parse(optionsArg)).toEqual({ quality: "best", language: "en" }); + }); + + it("throws when OCR fails", async () => { + vi.mocked(parseStdoutJson).mockReturnValue({ + success: false, + error: "No text detected", + }); + + await expect(extractText(FAKE_INPUT, FAKE_OUTPUT_DIR)).rejects.toThrow("No text detected"); + }); + + it("uses fallback error message when no error string provided", async () => { + vi.mocked(parseStdoutJson).mockReturnValue({ success: false }); + + await expect(extractText(FAKE_INPUT, FAKE_OUTPUT_DIR)).rejects.toThrow("OCR failed"); + }); +}); + +// ── colorize ────────────────────────────────────────────────────────── + +describe("colorize", () => { + beforeEach(() => { + vi.mocked(parseStdoutJson).mockReturnValue({ + success: true, + width: 800, + height: 600, + method: "deoldify", + }); + }); + + it("calls runPythonWithProgress with colorize.py", async () => { + await colorize(FAKE_INPUT, FAKE_OUTPUT_DIR); + + expect(runPythonWithProgress).toHaveBeenCalledWith( + "colorize.py", + expect.arrayContaining([ + expect.stringContaining("input_colorize.png"), + expect.stringContaining("output_colorize.png"), + ]), + expect.any(Object), + ); + }); + + it("returns ColorizeResult with buffer and metadata", async () => { + const result = await colorize(FAKE_INPUT, FAKE_OUTPUT_DIR); + + expect(result).toEqual({ + buffer: expect.any(Buffer), + width: 800, + height: 600, + method: "deoldify", + }); + }); + + it("passes intensity option", async () => { + await colorize(FAKE_INPUT, FAKE_OUTPUT_DIR, { intensity: 0.8 }); + + const args = vi.mocked(runPythonWithProgress).mock.calls[0][1]; + expect(JSON.parse(args[2])).toEqual({ intensity: 0.8 }); + }); + + it("throws when colorization fails", async () => { + vi.mocked(parseStdoutJson).mockReturnValue({ success: false }); + + await expect(colorize(FAKE_INPUT, FAKE_OUTPUT_DIR)).rejects.toThrow("Colorization failed"); + }); + + it("reads from alternate output_path when provided", async () => { + vi.mocked(parseStdoutJson).mockReturnValue({ + success: true, + width: 800, + height: 600, + output_path: "/tmp/alt-output.png", + }); + + await colorize(FAKE_INPUT, FAKE_OUTPUT_DIR); + expect(readFile).toHaveBeenCalledWith("/tmp/alt-output.png"); + }); +}); + +// ── blurFaces ───────────────────────────────────────────────────────── + +describe("blurFaces", () => { + beforeEach(() => { + vi.mocked(parseStdoutJson).mockReturnValue({ + success: true, + facesDetected: 2, + faces: [ + { x: 10, y: 20, w: 50, h: 60 }, + { x: 100, y: 120, w: 55, h: 65 }, + ], + }); + }); + + it("calls runPythonWithProgress with detect_faces.py", async () => { + await blurFaces(FAKE_INPUT, FAKE_OUTPUT_DIR); + + expect(runPythonWithProgress).toHaveBeenCalledWith( + "detect_faces.py", + expect.arrayContaining([ + expect.stringContaining("input_faces.png"), + expect.stringContaining("output_faces.png"), + ]), + expect.any(Object), + ); + }); + + it("returns BlurFacesResult with face regions", async () => { + const result = await blurFaces(FAKE_INPUT, FAKE_OUTPUT_DIR); + + expect(result.facesDetected).toBe(2); + expect(result.faces).toHaveLength(2); + expect(result.faces[0]).toEqual({ x: 10, y: 20, w: 50, h: 60 }); + expect(result.buffer).toBeInstanceOf(Buffer); + }); + + it("passes blur options", async () => { + await blurFaces(FAKE_INPUT, FAKE_OUTPUT_DIR, { + blurRadius: 20, + sensitivity: 0.5, + }); + + const args = vi.mocked(runPythonWithProgress).mock.calls[0][1]; + expect(JSON.parse(args[2])).toEqual({ blurRadius: 20, sensitivity: 0.5 }); + }); + + it("returns empty faces array when none detected", async () => { + vi.mocked(parseStdoutJson).mockReturnValue({ + success: true, + facesDetected: 0, + }); + + const result = await blurFaces(FAKE_INPUT, FAKE_OUTPUT_DIR); + expect(result.facesDetected).toBe(0); + expect(result.faces).toEqual([]); + }); + + it("throws when detection fails", async () => { + vi.mocked(parseStdoutJson).mockReturnValue({ success: false }); + + await expect(blurFaces(FAKE_INPUT, FAKE_OUTPUT_DIR)).rejects.toThrow("Face detection failed"); + }); +}); + +// ── detectFaces ─────────────────────────────────────────────────────── + +describe("detectFaces", () => { + beforeEach(() => { + vi.mocked(parseStdoutJson).mockReturnValue({ + success: true, + facesDetected: 1, + faces: [{ x: 50, y: 50, w: 100, h: 100 }], + }); + }); + + it("calls runPythonWithProgress with detectOnly option", async () => { + await detectFaces(FAKE_INPUT); + + const args = vi.mocked(runPythonWithProgress).mock.calls[0][1]; + const optionsArg = JSON.parse(args[2]); + expect(optionsArg.detectOnly).toBe(true); + }); + + it("returns DetectFacesResult without buffer", async () => { + const result = await detectFaces(FAKE_INPUT); + + expect(result).toEqual({ + facesDetected: 1, + faces: [{ x: 50, y: 50, w: 100, h: 100 }], + }); + // detectFaces does not return a buffer (unlike blurFaces) + expect(result).not.toHaveProperty("buffer"); + }); + + it("cleans up temp input file after success", async () => { + await detectFaces(FAKE_INPUT); + expect(unlink).toHaveBeenCalled(); + }); + + it("cleans up temp input file after failure", async () => { + vi.mocked(parseStdoutJson).mockReturnValue({ success: false }); + + await expect(detectFaces(FAKE_INPUT)).rejects.toThrow(); + expect(unlink).toHaveBeenCalled(); + }); +}); + +// ── enhanceFaces ────────────────────────────────────────────────────── + +describe("enhanceFaces", () => { + beforeEach(() => { + vi.mocked(parseStdoutJson).mockReturnValue({ + success: true, + facesDetected: 1, + faces: [{ x: 10, y: 20, w: 80, h: 90 }], + model: "gfpgan", + }); + }); + + it("calls runPythonWithProgress with enhance_faces.py", async () => { + await enhanceFaces(FAKE_INPUT, FAKE_OUTPUT_DIR); + + expect(runPythonWithProgress).toHaveBeenCalledWith( + "enhance_faces.py", + expect.arrayContaining([ + expect.stringContaining("input_enhance_faces.png"), + expect.stringContaining("output_enhance_faces.png"), + ]), + expect.any(Object), + ); + }); + + it("returns EnhanceFacesResult with model info", async () => { + const result = await enhanceFaces(FAKE_INPUT, FAKE_OUTPUT_DIR); + + expect(result).toEqual({ + buffer: expect.any(Buffer), + facesDetected: 1, + faces: [{ x: 10, y: 20, w: 80, h: 90 }], + model: "gfpgan", + }); + }); + + it("passes enhancement options", async () => { + await enhanceFaces(FAKE_INPUT, FAKE_OUTPUT_DIR, { + model: "codeformer", + strength: 0.7, + onlyCenterFace: true, + }); + + const args = vi.mocked(runPythonWithProgress).mock.calls[0][1]; + expect(JSON.parse(args[2])).toEqual({ + model: "codeformer", + strength: 0.7, + onlyCenterFace: true, + }); + }); + + it("throws when enhancement fails", async () => { + vi.mocked(parseStdoutJson).mockReturnValue({ + success: false, + error: "No faces found", + }); + + await expect(enhanceFaces(FAKE_INPUT, FAKE_OUTPUT_DIR)).rejects.toThrow("No faces found"); + }); + + it("defaults model to unknown when not provided by Python", async () => { + vi.mocked(parseStdoutJson).mockReturnValue({ + success: true, + facesDetected: 0, + }); + + const result = await enhanceFaces(FAKE_INPUT, FAKE_OUTPUT_DIR); + expect(result.model).toBe("unknown"); + expect(result.faces).toEqual([]); + }); +}); + +// ── detectFaceLandmarks ─────────────────────────────────────────────── + +describe("detectFaceLandmarks", () => { + beforeEach(() => { + vi.mocked(parseStdoutJson).mockReturnValue({ + success: true, + faceDetected: true, + landmarks: { + leftEye: { x: 100, y: 150 }, + rightEye: { x: 200, y: 150 }, + eyeCenter: { x: 150, y: 150 }, + chin: { x: 150, y: 300 }, + forehead: { x: 150, y: 80 }, + crown: { x: 150, y: 50 }, + nose: { x: 150, y: 200 }, + faceCenterX: 150, + }, + imageWidth: 800, + imageHeight: 600, + }); + }); + + it("calls runPythonWithProgress with face_landmarks.py", async () => { + await detectFaceLandmarks(FAKE_INPUT); + + expect(runPythonWithProgress).toHaveBeenCalledWith( + "face_landmarks.py", + expect.arrayContaining([expect.stringContaining("face_landmarks_"), "unused", "{}"]), + expect.any(Object), + ); + }); + + it("returns FaceLandmarksResult with all landmark points", async () => { + const result = await detectFaceLandmarks(FAKE_INPUT); + + expect(result.faceDetected).toBe(true); + expect(result.landmarks).toBeDefined(); + expect(result.landmarks!.leftEye).toEqual({ x: 100, y: 150 }); + expect(result.landmarks!.rightEye).toEqual({ x: 200, y: 150 }); + expect(result.imageWidth).toBe(800); + expect(result.imageHeight).toBe(600); + }); + + it("returns null landmarks when no face detected", async () => { + vi.mocked(parseStdoutJson).mockReturnValue({ + success: true, + faceDetected: false, + imageWidth: 800, + imageHeight: 600, + }); + + const result = await detectFaceLandmarks(FAKE_INPUT); + expect(result.faceDetected).toBe(false); + expect(result.landmarks).toBeNull(); + }); + + it("does not use sharp to convert to PNG (writes buffer directly)", async () => { + await detectFaceLandmarks(FAKE_INPUT); + + // face-landmarks writes inputBuffer directly, no sharp conversion + expect(writeFile).toHaveBeenCalledWith(expect.stringContaining("face_landmarks_"), FAKE_INPUT); + }); + + it("cleans up temp file in finally block", async () => { + await detectFaceLandmarks(FAKE_INPUT); + expect(unlink).toHaveBeenCalled(); + }); + + it("cleans up temp file even on failure", async () => { + vi.mocked(parseStdoutJson).mockReturnValue({ success: false }); + + await expect(detectFaceLandmarks(FAKE_INPUT)).rejects.toThrow(); + expect(unlink).toHaveBeenCalled(); + }); +}); + +// ── inpaint ─────────────────────────────────────────────────────────── + +describe("inpaint", () => { + beforeEach(() => { + vi.mocked(parseStdoutJson).mockReturnValue({ success: true }); + }); + + it("calls runPythonWithProgress with inpaint.py", async () => { + const maskBuffer = Buffer.from("fake-mask"); + await inpaint(FAKE_INPUT, maskBuffer, FAKE_OUTPUT_DIR); + + expect(runPythonWithProgress).toHaveBeenCalledWith( + "inpaint.py", + expect.arrayContaining([ + expect.stringContaining("input_inpaint.png"), + expect.stringContaining("mask_inpaint.png"), + expect.stringContaining("output_inpaint.png"), + ]), + expect.any(Object), + ); + }); + + it("writes both input and mask files", async () => { + const maskBuffer = Buffer.from("fake-mask"); + await inpaint(FAKE_INPUT, maskBuffer, FAKE_OUTPUT_DIR); + + // writeFile called for input and mask + expect(writeFile).toHaveBeenCalledTimes(2); + }); + + it("returns the output buffer", async () => { + const maskBuffer = Buffer.from("fake-mask"); + const outputBuf = Buffer.from("inpainted-result"); + vi.mocked(readFile).mockResolvedValueOnce(outputBuf); + + const result = await inpaint(FAKE_INPUT, maskBuffer, FAKE_OUTPUT_DIR); + expect(result).toBe(outputBuf); + }); + + it("throws when inpainting fails", async () => { + vi.mocked(parseStdoutJson).mockReturnValue({ + success: false, + error: "Mask is empty", + }); + + const maskBuffer = Buffer.from("fake-mask"); + await expect(inpaint(FAKE_INPUT, maskBuffer, FAKE_OUTPUT_DIR)).rejects.toThrow("Mask is empty"); + }); + + it("uses fallback error message when no error string", async () => { + vi.mocked(parseStdoutJson).mockReturnValue({ success: false }); + + const maskBuffer = Buffer.from("fake-mask"); + await expect(inpaint(FAKE_INPUT, maskBuffer, FAKE_OUTPUT_DIR)).rejects.toThrow( + "Inpainting failed", + ); + }); +}); + +// ── noiseRemoval ────────────────────────────────────────────────────── + +describe("noiseRemoval", () => { + beforeEach(() => { + vi.mocked(parseStdoutJson).mockReturnValue({ + success: true, + width: 800, + height: 600, + format: "png", + tier: "balanced", + }); + }); + + it("calls runPythonWithProgress with noise_removal.py", async () => { + await noiseRemoval(FAKE_INPUT, FAKE_OUTPUT_DIR); + + expect(runPythonWithProgress).toHaveBeenCalledWith( + "noise_removal.py", + expect.arrayContaining([ + expect.stringContaining("input_denoise.png"), + expect.stringContaining("output_denoise.png"), + ]), + expect.any(Object), + ); + }); + + it("returns NoiseRemovalResult with all fields", async () => { + const result = await noiseRemoval(FAKE_INPUT, FAKE_OUTPUT_DIR); + + expect(result).toEqual({ + buffer: expect.any(Buffer), + width: 800, + height: 600, + format: "png", + tier: "balanced", + }); + }); + + it("passes strength and tier options", async () => { + await noiseRemoval(FAKE_INPUT, FAKE_OUTPUT_DIR, { + tier: "aggressive", + strength: 0.9, + }); + + const args = vi.mocked(runPythonWithProgress).mock.calls[0][1]; + expect(JSON.parse(args[2])).toEqual({ tier: "aggressive", strength: 0.9 }); + }); + + it("defaults tier from options when Python omits it", async () => { + vi.mocked(parseStdoutJson).mockReturnValue({ + success: true, + width: 800, + height: 600, + }); + + const result = await noiseRemoval(FAKE_INPUT, FAKE_OUTPUT_DIR, { tier: "aggressive" }); + expect(result.tier).toBe("aggressive"); + expect(result.format).toBe("png"); + }); + + it("throws on failure", async () => { + vi.mocked(parseStdoutJson).mockReturnValue({ success: false }); + + await expect(noiseRemoval(FAKE_INPUT, FAKE_OUTPUT_DIR)).rejects.toThrow("Noise removal failed"); + }); +}); + +// ── removeRedEye ────────────────────────────────────────────────────── + +describe("removeRedEye", () => { + beforeEach(() => { + vi.mocked(parseStdoutJson).mockReturnValue({ + success: true, + facesDetected: 1, + eyesCorrected: 2, + width: 800, + height: 600, + format: "png", + }); + }); + + it("calls runPythonWithProgress with red_eye_removal.py", async () => { + await removeRedEye(FAKE_INPUT, FAKE_OUTPUT_DIR); + + expect(runPythonWithProgress).toHaveBeenCalledWith( + "red_eye_removal.py", + expect.arrayContaining([ + expect.stringContaining("input_redeye.png"), + expect.stringContaining("output_redeye.png"), + ]), + expect.any(Object), + ); + }); + + it("returns RedEyeRemovalResult with correction counts", async () => { + const result = await removeRedEye(FAKE_INPUT, FAKE_OUTPUT_DIR); + + expect(result).toEqual({ + buffer: expect.any(Buffer), + facesDetected: 1, + eyesCorrected: 2, + width: 800, + height: 600, + format: "png", + }); + }); + + it("defaults facesDetected and eyesCorrected to 0", async () => { + vi.mocked(parseStdoutJson).mockReturnValue({ + success: true, + width: 800, + height: 600, + }); + + const result = await removeRedEye(FAKE_INPUT, FAKE_OUTPUT_DIR); + expect(result.facesDetected).toBe(0); + expect(result.eyesCorrected).toBe(0); + }); + + it("passes sensitivity and strength options", async () => { + await removeRedEye(FAKE_INPUT, FAKE_OUTPUT_DIR, { + sensitivity: 0.8, + strength: 0.6, + }); + + const args = vi.mocked(runPythonWithProgress).mock.calls[0][1]; + expect(JSON.parse(args[2])).toEqual({ sensitivity: 0.8, strength: 0.6 }); + }); + + it("throws on failure", async () => { + vi.mocked(parseStdoutJson).mockReturnValue({ success: false }); + + await expect(removeRedEye(FAKE_INPUT, FAKE_OUTPUT_DIR)).rejects.toThrow( + "Red eye removal failed", + ); + }); +}); + +// ── restorePhoto ────────────────────────────────────────────────────── + +describe("restorePhoto", () => { + beforeEach(() => { + vi.mocked(parseStdoutJson).mockReturnValue({ + success: true, + width: 800, + height: 600, + steps: ["denoise", "face_enhance"], + scratchCoverage: 0.15, + facesEnhanced: 2, + isGrayscale: true, + colorized: true, + }); + }); + + it("calls runPythonWithProgress with restore.py", async () => { + await restorePhoto(FAKE_INPUT, FAKE_OUTPUT_DIR); + + expect(runPythonWithProgress).toHaveBeenCalledWith( + "restore.py", + expect.arrayContaining([ + expect.stringContaining("input_restore.png"), + expect.stringContaining("output_restore.png"), + ]), + expect.any(Object), + ); + }); + + it("returns RestorePhotoResult with all metadata", async () => { + const result = await restorePhoto(FAKE_INPUT, FAKE_OUTPUT_DIR); + + expect(result).toEqual({ + buffer: expect.any(Buffer), + width: 800, + height: 600, + steps: ["denoise", "face_enhance"], + scratchCoverage: 0.15, + facesEnhanced: 2, + isGrayscale: true, + colorized: true, + }); + }); + + it("passes restoration options including scratch and colorize", async () => { + await restorePhoto(FAKE_INPUT, FAKE_OUTPUT_DIR, { + scratchRemoval: true, + faceEnhancement: true, + colorize: true, + }); + + const args = vi.mocked(runPythonWithProgress).mock.calls[0][1]; + expect(JSON.parse(args[2])).toEqual({ + scratchRemoval: true, + faceEnhancement: true, + colorize: true, + }); + }); + + it("defaults optional fields when Python omits them", async () => { + vi.mocked(parseStdoutJson).mockReturnValue({ + success: true, + width: 400, + height: 300, + }); + + const result = await restorePhoto(FAKE_INPUT, FAKE_OUTPUT_DIR); + expect(result.steps).toEqual([]); + expect(result.scratchCoverage).toBe(0); + expect(result.facesEnhanced).toBe(0); + expect(result.isGrayscale).toBe(false); + expect(result.colorized).toBe(false); + }); + + it("throws on failure", async () => { + vi.mocked(parseStdoutJson).mockReturnValue({ success: false }); + + await expect(restorePhoto(FAKE_INPUT, FAKE_OUTPUT_DIR)).rejects.toThrow( + "Photo restoration failed", + ); + }); +}); + +// ── error propagation from runPythonWithProgress ────────────────────── + +describe("error propagation from bridge", () => { + it("propagates bridge rejection through tool functions", async () => { + vi.mocked(runPythonWithProgress).mockRejectedValue(new Error("Python script timed out")); + + await expect(extractText(FAKE_INPUT, FAKE_OUTPUT_DIR)).rejects.toThrow( + "Python script timed out", + ); + }); + + it("propagates OOM errors through tool functions", async () => { + vi.mocked(runPythonWithProgress).mockRejectedValue( + new Error("Process killed (out of memory) -- try a lighter model or smaller image"), + ); + + await expect(colorize(FAKE_INPUT, FAKE_OUTPUT_DIR)).rejects.toThrow("out of memory"); + }); +}); diff --git a/tests/unit/api/format-decoders.test.ts b/tests/unit/api/format-decoders.test.ts new file mode 100644 index 00000000..ecbb5240 --- /dev/null +++ b/tests/unit/api/format-decoders.test.ts @@ -0,0 +1,94 @@ +import { describe, expect, it } from "vitest"; +import { decodeToSharpCompat, needsCliDecode } from "../../../apps/api/src/lib/format-decoders.js"; + +// ========================================================================== +// needsCliDecode +// ========================================================================== + +describe("needsCliDecode", () => { + it("returns true for raw format", () => { + expect(needsCliDecode("raw")).toBe(true); + }); + + it("returns true for ico format", () => { + expect(needsCliDecode("ico")).toBe(true); + }); + + it("returns true for tga format", () => { + expect(needsCliDecode("tga")).toBe(true); + }); + + it("returns true for psd format", () => { + expect(needsCliDecode("psd")).toBe(true); + }); + + it("returns true for exr format", () => { + expect(needsCliDecode("exr")).toBe(true); + }); + + it("returns true for hdr format", () => { + expect(needsCliDecode("hdr")).toBe(true); + }); + + it("returns false for jpeg format", () => { + expect(needsCliDecode("jpeg")).toBe(false); + }); + + it("returns false for png format", () => { + expect(needsCliDecode("png")).toBe(false); + }); + + it("returns false for webp format", () => { + expect(needsCliDecode("webp")).toBe(false); + }); + + it("returns false for gif format", () => { + expect(needsCliDecode("gif")).toBe(false); + }); + + it("returns false for avif format", () => { + expect(needsCliDecode("avif")).toBe(false); + }); + + it("returns false for svg format", () => { + expect(needsCliDecode("svg")).toBe(false); + }); + + it("returns false for empty string", () => { + expect(needsCliDecode("")).toBe(false); + }); + + it("returns false for unknown format", () => { + expect(needsCliDecode("bmp")).toBe(false); + }); +}); + +// ========================================================================== +// decodeToSharpCompat — routing logic (default passthrough) +// ========================================================================== + +describe("decodeToSharpCompat", () => { + it("returns buffer unchanged for unknown/native formats", async () => { + const buf = Buffer.from("test data"); + const result = await decodeToSharpCompat(buf, "jpeg"); + expect(result).toBe(buf); + }); + + it("returns buffer unchanged for png format", async () => { + const buf = Buffer.from("png data"); + const result = await decodeToSharpCompat(buf, "png"); + expect(result).toBe(buf); + }); + + it("returns buffer unchanged for empty format string", async () => { + const buf = Buffer.from("some bytes"); + const result = await decodeToSharpCompat(buf, ""); + expect(result).toBe(buf); + }); + + it("returns buffer unchanged for webp format", async () => { + const buf = Buffer.from("webp"); + const result = await decodeToSharpCompat(buf, "webp"); + expect(result).toBe(buf); + }); +}); diff --git a/tests/unit/api/page-range.test.ts b/tests/unit/api/page-range.test.ts new file mode 100644 index 00000000..0236ea06 --- /dev/null +++ b/tests/unit/api/page-range.test.ts @@ -0,0 +1,114 @@ +import { describe, expect, it } from "vitest"; +import { parsePageRange } from "../../../apps/api/src/routes/tools/pdf-to-image.js"; + +describe("parsePageRange", () => { + // -- "all" / empty → full range ------------------------------------------- + + it("returns all pages for empty string", () => { + expect(parsePageRange("", 5)).toEqual([1, 2, 3, 4, 5]); + }); + + it("returns all pages for 'all'", () => { + expect(parsePageRange("all", 3)).toEqual([1, 2, 3]); + }); + + it("returns all pages for 'ALL' (case-insensitive)", () => { + expect(parsePageRange("ALL", 4)).toEqual([1, 2, 3, 4]); + }); + + it("returns all pages for ' all ' (trimmed)", () => { + expect(parsePageRange(" all ", 2)).toEqual([1, 2]); + }); + + // -- Single pages --------------------------------------------------------- + + it("parses a single page number", () => { + expect(parsePageRange("3", 10)).toEqual([3]); + }); + + it("parses multiple single pages", () => { + expect(parsePageRange("1, 3, 5", 10)).toEqual([1, 3, 5]); + }); + + it("deduplicates repeated pages", () => { + expect(parsePageRange("2, 2, 3, 3", 5)).toEqual([2, 3]); + }); + + it("sorts pages in ascending order", () => { + expect(parsePageRange("5, 1, 3", 10)).toEqual([1, 3, 5]); + }); + + // -- Ranges --------------------------------------------------------------- + + it("parses a simple range", () => { + expect(parsePageRange("2-4", 10)).toEqual([2, 3, 4]); + }); + + it("parses multiple ranges", () => { + expect(parsePageRange("1-3, 7-9", 10)).toEqual([1, 2, 3, 7, 8, 9]); + }); + + it("parses mixed single pages and ranges", () => { + expect(parsePageRange("1, 3-5, 8", 10)).toEqual([1, 3, 4, 5, 8]); + }); + + it("handles ranges with spaces", () => { + expect(parsePageRange(" 2 - 4 , 6 ", 10)).toEqual([2, 3, 4, 6]); + }); + + it("deduplicates overlapping ranges", () => { + expect(parsePageRange("1-3, 2-4", 5)).toEqual([1, 2, 3, 4]); + }); + + // -- Edge cases ----------------------------------------------------------- + + it("handles single page equal to totalPages", () => { + expect(parsePageRange("5", 5)).toEqual([5]); + }); + + it("handles range ending at totalPages", () => { + expect(parsePageRange("3-5", 5)).toEqual([3, 4, 5]); + }); + + it("handles a range of one page (start equals end)", () => { + expect(parsePageRange("3-3", 5)).toEqual([3]); + }); + + // -- Error cases ---------------------------------------------------------- + + it("throws for page exceeding totalPages", () => { + expect(() => parsePageRange("6", 5)).toThrow("out of range"); + }); + + it("throws for range exceeding totalPages", () => { + expect(() => parsePageRange("3-10", 5)).toThrow("out of range"); + }); + + it("throws for page 0 (pages start at 1)", () => { + expect(() => parsePageRange("0", 5)).toThrow("positive"); + }); + + it("throws for negative page number", () => { + expect(() => parsePageRange("-1", 5)).toThrow(); + }); + + it("throws for reversed range (start > end)", () => { + expect(() => parsePageRange("5-3", 10)).toThrow("start exceeds end"); + }); + + it("throws for non-integer page number", () => { + expect(() => parsePageRange("1.5", 5)).toThrow(); + }); + + it("throws for non-numeric input", () => { + expect(() => parsePageRange("abc", 5)).toThrow(); + }); + + it("throws for empty segment (trailing comma)", () => { + expect(() => parsePageRange("1,", 5)).toThrow("Invalid page range format"); + }); + + it("throws for empty segment (leading comma)", () => { + expect(() => parsePageRange(",1", 5)).toThrow("Invalid page range format"); + }); +}); diff --git a/tests/unit/api/utilities.test.ts b/tests/unit/api/utilities.test.ts index 52271fe8..9a805975 100644 --- a/tests/unit/api/utilities.test.ts +++ b/tests/unit/api/utilities.test.ts @@ -354,6 +354,277 @@ describe("validateImageBuffer", () => { const result = await validateImageBuffer(exe); expect(result.valid).toBe(false); }); + + // -- Null-byte buffers ---------------------------------------------------- + + it("rejects a buffer that is entirely null bytes (small)", async () => { + const nullBuf = Buffer.alloc(32); // all zeros + const result = await validateImageBuffer(nullBuf); + expect(result.valid).toBe(false); + if (!result.valid) { + expect(result.reason).toBe("File contains no image data"); + } + }); + + it("rejects a large buffer that is entirely null bytes", async () => { + const nullBuf = Buffer.alloc(1024); // all zeros, > 64 bytes + const result = await validateImageBuffer(nullBuf); + expect(result.valid).toBe(false); + if (!result.valid) { + expect(result.reason).toBe("File contains no image data"); + } + }); + + // -- SVG detection -------------------------------------------------------- + + it("accepts an SVG buffer with valid XML", async () => { + const svg = Buffer.from( + '', + ); + const result = await validateImageBuffer(svg); + expect(result.valid).toBe(true); + if (result.valid) { + expect(result.format).toBe("svg"); + } + }); + + // -- HDR text detection --------------------------------------------------- + + it("accepts an HDR buffer with #?RADIANCE header", async () => { + // Build a minimal buffer that starts with the HDR magic text + const hdrHeader = Buffer.from("#?RADIANCE\n"); + const padding = Buffer.alloc(100); + const hdrBuf = Buffer.concat([hdrHeader, padding]); + const result = await validateImageBuffer(hdrBuf); + // HDR is a CLI_DECODED_FORMAT, so it bypasses sharp metadata + expect(result.valid).toBe(true); + if (result.valid) { + expect(result.format).toBe("hdr"); + expect(result.width).toBe(0); + expect(result.height).toBe(0); + } + }); + + it("accepts an HDR buffer with #?RGBE header", async () => { + const hdrHeader = Buffer.from("#?RGBE\n"); + const padding = Buffer.alloc(100); + const hdrBuf = Buffer.concat([hdrHeader, padding]); + const result = await validateImageBuffer(hdrBuf); + expect(result.valid).toBe(true); + if (result.valid) { + expect(result.format).toBe("hdr"); + } + }); + + it("does not detect HDR for a buffer shorter than 10 bytes", async () => { + const shortBuf = Buffer.from("#?RADIAN"); // 8 bytes, too short + const result = await validateImageBuffer(shortBuf); + expect(result.valid).toBe(false); + }); + + // -- RAW extension differentiation ---------------------------------------- + + it("detects RAW format when TIFF magic bytes + RAW extension (DNG)", async () => { + const tiffLE = Buffer.from([0x49, 0x49, 0x2a, 0x00, 0x08, 0x00, 0x00, 0x00]); + const result = await validateImageBuffer(tiffLE, "photo.dng"); + // RAW is a CLI_DECODED_FORMAT, so width/height are 0 + expect(result.valid).toBe(true); + if (result.valid) { + expect(result.format).toBe("raw"); + expect(result.width).toBe(0); + expect(result.height).toBe(0); + } + }); + + it("detects RAW format with CR2 extension", async () => { + const tiffLE = Buffer.from([0x49, 0x49, 0x2a, 0x00, 0x08, 0x00, 0x00, 0x00]); + const result = await validateImageBuffer(tiffLE, "photo.cr2"); + expect(result.valid).toBe(true); + if (result.valid) { + expect(result.format).toBe("raw"); + } + }); + + it("detects RAW format with NEF extension", async () => { + const tiffLE = Buffer.from([0x49, 0x49, 0x2a, 0x00, 0x08, 0x00, 0x00, 0x00]); + const result = await validateImageBuffer(tiffLE, "photo.nef"); + expect(result.valid).toBe(true); + if (result.valid) { + expect(result.format).toBe("raw"); + } + }); + + // -- TGA extension-only detection ----------------------------------------- + + it("detects TGA format by extension (no magic bytes)", async () => { + // TGA has no magic bytes, so detection is extension-only + const randomBytes = Buffer.from([0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08]); + const result = await validateImageBuffer(randomBytes, "image.tga"); + expect(result.valid).toBe(true); + if (result.valid) { + expect(result.format).toBe("tga"); + expect(result.width).toBe(0); + expect(result.height).toBe(0); + } + }); + + // -- CLI decoded format bypass (PSD, EXR) --------------------------------- + + it("detects PSD format and bypasses sharp dimension check", async () => { + // PSD magic: "8BPS" + const psdBuf = Buffer.alloc(64); + psdBuf[0] = 0x38; + psdBuf[1] = 0x42; + psdBuf[2] = 0x50; + psdBuf[3] = 0x53; + const result = await validateImageBuffer(psdBuf); + expect(result.valid).toBe(true); + if (result.valid) { + expect(result.format).toBe("psd"); + expect(result.width).toBe(0); + expect(result.height).toBe(0); + } + }); + + it("detects EXR format and bypasses sharp dimension check", async () => { + // OpenEXR magic bytes + const exrBuf = Buffer.alloc(64); + exrBuf[0] = 0x76; + exrBuf[1] = 0x2f; + exrBuf[2] = 0x31; + exrBuf[3] = 0x01; + const result = await validateImageBuffer(exrBuf); + expect(result.valid).toBe(true); + if (result.valid) { + expect(result.format).toBe("exr"); + expect(result.width).toBe(0); + expect(result.height).toBe(0); + } + }); + + // -- ICO detection -------------------------------------------------------- + + it("detects ICO format via magic bytes", async () => { + const icoBuf = Buffer.alloc(64); + icoBuf[0] = 0x00; + icoBuf[1] = 0x00; + icoBuf[2] = 0x01; + icoBuf[3] = 0x00; + // Need non-zero bytes past first 64 positions to avoid null-byte rejection + icoBuf[4] = 0x01; + const result = await validateImageBuffer(icoBuf); + expect(result.valid).toBe(true); + if (result.valid) { + expect(result.format).toBe("ico"); + expect(result.width).toBe(0); + expect(result.height).toBe(0); + } + }); + + // -- AVIF/HEIF ftyp brand verification ----------------------------------- + + it("rejects ftyp box with unrecognized brand (not avif/heic)", async () => { + // Build a buffer with ftyp at offset 4 but brand "mp41" (not avif or heif) + const buf = Buffer.alloc(16); + buf.write("ftyp", 4, "ascii"); + buf.write("mp41", 8, "ascii"); + const result = await validateImageBuffer(buf); + expect(result.valid).toBe(false); + }); + + // -- JXL detection -------------------------------------------------------- + + it("detects JXL ISOBMFF container format", async () => { + const jxlBuf = Buffer.alloc(64); + // JXL ISOBMFF magic: 00 00 00 0C 4A 58 4C 20 + jxlBuf[0] = 0x00; + jxlBuf[1] = 0x00; + jxlBuf[2] = 0x00; + jxlBuf[3] = 0x0c; + jxlBuf[4] = 0x4a; + jxlBuf[5] = 0x58; + jxlBuf[6] = 0x4c; + jxlBuf[7] = 0x20; + // Need non-null bytes to avoid null-byte rejection + jxlBuf[8] = 0x01; + const result = await validateImageBuffer(jxlBuf); + // JXL is not in CLI_DECODED_FORMATS, so sharp metadata may fail + expect(result).toBeDefined(); + if (result.valid) { + expect(result.format).toBe("jxl"); + } + }); + + it("detects JXL raw codestream format", async () => { + const jxlRaw = Buffer.alloc(64); + jxlRaw[0] = 0xff; + jxlRaw[1] = 0x0a; + // Need more non-zero data + jxlRaw[2] = 0x01; + const result = await validateImageBuffer(jxlRaw); + expect(result).toBeDefined(); + if (result.valid) { + expect(result.format).toBe("jxl"); + } + }); +}); + +// --------------------------------------------------------------------------- +// 1b. isRawExtension +// --------------------------------------------------------------------------- + +describe("isRawExtension", () => { + let isRawExtension: typeof import("../../../apps/api/src/lib/file-validation.js").isRawExtension; + + beforeEach(async () => { + const mod = await import("../../../apps/api/src/lib/file-validation.js"); + isRawExtension = mod.isRawExtension; + }); + + it("returns true for DNG extension", () => { + expect(isRawExtension("dng")).toBe(true); + }); + + it("returns true for CR2 extension", () => { + expect(isRawExtension("cr2")).toBe(true); + }); + + it("returns true for NEF extension", () => { + expect(isRawExtension("nef")).toBe(true); + }); + + it("returns true for ARW extension", () => { + expect(isRawExtension("arw")).toBe(true); + }); + + it("returns true for ORF extension", () => { + expect(isRawExtension("orf")).toBe(true); + }); + + it("returns true for RW2 extension", () => { + expect(isRawExtension("rw2")).toBe(true); + }); + + it("returns true for uppercase extensions", () => { + expect(isRawExtension("DNG")).toBe(true); + expect(isRawExtension("CR2")).toBe(true); + }); + + it("returns true with leading dot", () => { + expect(isRawExtension(".dng")).toBe(true); + expect(isRawExtension(".CR2")).toBe(true); + }); + + it("returns false for non-RAW extensions", () => { + expect(isRawExtension("png")).toBe(false); + expect(isRawExtension("jpg")).toBe(false); + expect(isRawExtension("tiff")).toBe(false); + expect(isRawExtension("webp")).toBe(false); + }); + + it("returns false for empty string", () => { + expect(isRawExtension("")).toBe(false); + }); }); // --------------------------------------------------------------------------- diff --git a/tests/unit/image-engine/detect.test.ts b/tests/unit/image-engine/detect.test.ts new file mode 100644 index 00000000..2fad437a --- /dev/null +++ b/tests/unit/image-engine/detect.test.ts @@ -0,0 +1,231 @@ +import { readFileSync } from "node:fs"; +import path from "node:path"; +import { detectFormat } from "@ashim/image-engine"; +import { describe, expect, it } from "vitest"; + +const FORMATS_DIR = path.resolve(__dirname, "../../fixtures/formats"); + +// --------------------------------------------------------------------------- +// Format detection via Sharp metadata + magic byte fallback +// --------------------------------------------------------------------------- +describe("detectFormat", () => { + // Formats that Sharp can natively detect via metadata + const sharpNativeFormats: Array<{ file: string; expected: string }> = [ + { file: "sample.jpg", expected: "jpeg" }, + { file: "sample.png", expected: "png" }, + { file: "sample.webp", expected: "webp" }, + { file: "sample.gif", expected: "gif" }, + { file: "sample.avif", expected: "heif" }, // Sharp reports AVIF as "heif" + { file: "sample.tiff", expected: "tiff" }, + { file: "sample.svg", expected: "svg" }, + ]; + + for (const { file, expected } of sharpNativeFormats) { + it(`detects ${file} as "${expected}" via Sharp metadata`, async () => { + const buffer = readFileSync(path.join(FORMATS_DIR, file)); + const format = await detectFormat(buffer); + expect(format).toBe(expected); + }); + } + + // Formats that require magic byte fallback because Sharp cannot parse them + const magicByteFormats: Array<{ file: string; expected: string }> = [ + { file: "sample.bmp", expected: "bmp" }, + { file: "sample.ico", expected: "ico" }, + { file: "sample.psd", expected: "psd" }, + { file: "sample.exr", expected: "exr" }, + ]; + + for (const { file, expected } of magicByteFormats) { + it(`detects ${file} as "${expected}" via magic bytes`, async () => { + const buffer = readFileSync(path.join(FORMATS_DIR, file)); + const format = await detectFormat(buffer); + expect(format).toBe(expected); + }); + } + + // HEIC/HEIF - Sharp may or may not handle these depending on libheif + it("detects sample.heic via Sharp or magic bytes", async () => { + const buffer = readFileSync(path.join(FORMATS_DIR, "sample.heic")); + const format = await detectFormat(buffer); + // Sharp may report "heif" or magic bytes may detect "avif" (ftyp box) + expect(["heif", "avif"]).toContain(format); + }); + + it("detects sample.heif via Sharp or magic bytes", async () => { + const buffer = readFileSync(path.join(FORMATS_DIR, "sample.heif")); + const format = await detectFormat(buffer); + expect(["heif", "avif"]).toContain(format); + }); + + // JXL detection + it("detects sample.jxl", async () => { + const buffer = readFileSync(path.join(FORMATS_DIR, "sample.jxl")); + const format = await detectFormat(buffer); + // Sharp may detect "jxl" natively or magic bytes catch it + expect(["jxl", "unknown"]).toContain(format); + }); + + // --------------------------------------------------------------------------- + // Synthetic magic byte buffers + // --------------------------------------------------------------------------- + describe("magic byte detection with synthetic buffers", () => { + it("detects PNG magic bytes", async () => { + const buf = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0, 0, 0, 0]); + const format = await detectFormat(buf); + expect(format).toBe("png"); + }); + + it("detects JPEG magic bytes", async () => { + const buf = Buffer.from([0xff, 0xd8, 0xff, 0xe0, 0, 0, 0, 0]); + const format = await detectFormat(buf); + expect(format).toBe("jpeg"); + }); + + it("detects GIF magic bytes", async () => { + const buf = Buffer.from([0x47, 0x49, 0x46, 0x38, 0x39, 0x61, 0, 0, 0, 0, 0, 0, 0]); + const format = await detectFormat(buf); + expect(format).toBe("gif"); + }); + + it("detects WEBP magic bytes (RIFF + WEBP)", async () => { + // RIFF....WEBP + const buf = Buffer.alloc(16); + buf[0] = 0x52; + buf[1] = 0x49; + buf[2] = 0x46; + buf[3] = 0x46; // RIFF + buf[8] = 0x57; + buf[9] = 0x45; + buf[10] = 0x42; + buf[11] = 0x50; // WEBP + const format = await detectFormat(buf); + expect(format).toBe("webp"); + }); + + it("rejects RIFF without WEBP signature", async () => { + const buf = Buffer.alloc(16); + buf[0] = 0x52; + buf[1] = 0x49; + buf[2] = 0x46; + buf[3] = 0x46; // RIFF + buf[8] = 0x41; + buf[9] = 0x56; + buf[10] = 0x49; + buf[11] = 0x20; // AVI + const format = await detectFormat(buf); + // Should not detect as webp; might detect as tiff or unknown + expect(format).not.toBe("webp"); + }); + + it("detects little-endian TIFF magic bytes", async () => { + const buf = Buffer.from([0x49, 0x49, 0x2a, 0x00, 0, 0, 0, 0, 0, 0, 0, 0]); + const format = await detectFormat(buf); + expect(format).toBe("tiff"); + }); + + it("detects big-endian TIFF magic bytes", async () => { + const buf = Buffer.from([0x4d, 0x4d, 0x00, 0x2a, 0, 0, 0, 0, 0, 0, 0, 0]); + const format = await detectFormat(buf); + expect(format).toBe("tiff"); + }); + + it("detects BMP magic bytes", async () => { + const buf = Buffer.from([0x42, 0x4d, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]); + const format = await detectFormat(buf); + expect(format).toBe("bmp"); + }); + + it("detects ICO magic bytes", async () => { + const buf = Buffer.from([0x00, 0x00, 0x01, 0x00, 0, 0, 0, 0, 0, 0, 0, 0]); + const format = await detectFormat(buf); + expect(format).toBe("ico"); + }); + + it("detects PSD magic bytes", async () => { + const buf = Buffer.from([0x38, 0x42, 0x50, 0x53, 0, 0, 0, 0, 0, 0, 0, 0]); + const format = await detectFormat(buf); + expect(format).toBe("psd"); + }); + + it("detects OpenEXR magic bytes", async () => { + const buf = Buffer.from([0x76, 0x2f, 0x31, 0x01, 0, 0, 0, 0, 0, 0, 0, 0]); + const format = await detectFormat(buf); + expect(format).toBe("exr"); + }); + + it("detects AVIF magic bytes (ftyp at offset 4 with avif brand)", async () => { + const buf = Buffer.alloc(16); + // ftyp at offset 4 + buf[4] = 0x66; + buf[5] = 0x74; + buf[6] = 0x79; + buf[7] = 0x70; + // brand "avif" at offset 8 + buf[8] = 0x61; + buf[9] = 0x76; + buf[10] = 0x69; + buf[11] = 0x66; + const format = await detectFormat(buf); + expect(format).toBe("avif"); + }); + + it("detects AVIF magic bytes with avis brand", async () => { + const buf = Buffer.alloc(16); + buf[4] = 0x66; + buf[5] = 0x74; + buf[6] = 0x79; + buf[7] = 0x70; + buf[8] = 0x61; + buf[9] = 0x76; + buf[10] = 0x69; + buf[11] = 0x73; // "avis" + const format = await detectFormat(buf); + expect(format).toBe("avif"); + }); + + it("rejects ftyp box with non-AVIF brand", async () => { + const buf = Buffer.alloc(16); + buf[4] = 0x66; + buf[5] = 0x74; + buf[6] = 0x79; + buf[7] = 0x70; + buf[8] = 0x69; + buf[9] = 0x73; + buf[10] = 0x6f; + buf[11] = 0x6d; // "isom" + const format = await detectFormat(buf); + expect(format).not.toBe("avif"); + }); + + it("detects JXL ISOBMFF container magic bytes", async () => { + const buf = Buffer.from([0x00, 0x00, 0x00, 0x0c, 0x4a, 0x58, 0x4c, 0x20, 0, 0, 0, 0]); + const format = await detectFormat(buf); + expect(format).toBe("jxl"); + }); + + it("detects JXL raw codestream magic bytes", async () => { + const buf = Buffer.from([0xff, 0x0a, 0, 0, 0, 0, 0, 0]); + const format = await detectFormat(buf); + // Note: 0xFF 0x0A starts with 0xFF which also matches JPEG prefix, + // but JPEG needs 0xFF 0xD8 0xFF, so the JPEG check fails and JXL wins + expect(format).toBe("jxl"); + }); + + it("returns 'unknown' for unrecognized bytes", async () => { + const buf = Buffer.from([0xde, 0xad, 0xbe, 0xef, 0, 0, 0, 0, 0, 0, 0, 0]); + const format = await detectFormat(buf); + expect(format).toBe("unknown"); + }); + + it("returns 'unknown' for empty buffer", async () => { + const format = await detectFormat(Buffer.alloc(0)); + expect(format).toBe("unknown"); + }); + + it("returns 'unknown' for very short buffer", async () => { + const format = await detectFormat(Buffer.from([0x89])); + expect(format).toBe("unknown"); + }); + }); +}); diff --git a/tests/unit/image-engine/engine.test.ts b/tests/unit/image-engine/engine.test.ts new file mode 100644 index 00000000..80df5c2e --- /dev/null +++ b/tests/unit/image-engine/engine.test.ts @@ -0,0 +1,235 @@ +/** + * Engine-level tests for processImage. + * + * The existing operations.test.ts covers individual operations and basic + * pipeline chaining extensively. This file focuses on: + * - Format conversion via the outputFormat parameter + * - Edge cases around operation ordering + * - The Operation type contract + */ +import { readFileSync } from "node:fs"; +import { createRequire } from "node:module"; +import path from "node:path"; +import { beforeAll, describe, expect, it } from "vitest"; + +const require = createRequire( + path.resolve(__dirname, "../../../packages/image-engine/src/index.ts"), +); +const sharp = require("sharp") as typeof import("sharp").default; + +import { processImage } from "@ashim/image-engine"; + +const FIXTURES_DIR = path.resolve(__dirname, "../../fixtures"); +const FORMATS_DIR = path.resolve(__dirname, "../../fixtures/formats"); + +let png200x150: Buffer; +let jpg100x100: Buffer; +let webp50x50: Buffer; + +beforeAll(() => { + png200x150 = readFileSync(path.join(FIXTURES_DIR, "test-200x150.png")); + jpg100x100 = readFileSync(path.join(FIXTURES_DIR, "test-100x100.jpg")); + webp50x50 = readFileSync(path.join(FIXTURES_DIR, "test-50x50.webp")); +}); + +// --------------------------------------------------------------------------- +// Output format conversion +// --------------------------------------------------------------------------- +describe("processImage output format", () => { + it("converts PNG input to JPEG output", async () => { + const result = await processImage(png200x150, [], "jpg"); + expect(result.info.format).toBe("jpeg"); + expect(result.buffer.length).toBeGreaterThan(0); + }); + + it("converts PNG input to WebP output", async () => { + const result = await processImage(png200x150, [], "webp"); + expect(result.info.format).toBe("webp"); + }); + + it("converts PNG input to AVIF output", async () => { + const result = await processImage(png200x150, [], "avif"); + // Sharp reports AVIF as "heif" + expect(result.info.format).toBe("heif"); + }); + + it("converts PNG input to TIFF output", async () => { + const result = await processImage(png200x150, [], "tiff"); + expect(result.info.format).toBe("tiff"); + }); + + it("converts PNG input to GIF output", async () => { + const result = await processImage(png200x150, [], "gif"); + expect(result.info.format).toBe("gif"); + }); + + it("converts JPEG input to PNG output", async () => { + const result = await processImage(jpg100x100, [], "png"); + expect(result.info.format).toBe("png"); + }); + + it("converts WebP input to PNG output", async () => { + const result = await processImage(webp50x50, [], "png"); + expect(result.info.format).toBe("png"); + }); + + it("preserves input format when no outputFormat specified", async () => { + const result = await processImage(png200x150, []); + expect(result.info.format).toBe("png"); + }); + + it("preserves JPEG format when no outputFormat specified", async () => { + const result = await processImage(jpg100x100, []); + expect(result.info.format).toBe("jpeg"); + }); + + it("throws on unsupported output format", async () => { + await expect(processImage(png200x150, [], "bmp" as any)).rejects.toThrow( + "Unsupported output format: bmp", + ); + }); +}); + +// --------------------------------------------------------------------------- +// Operations + output format combined +// --------------------------------------------------------------------------- +describe("processImage operations + format conversion", () => { + it("resize then convert to JPEG", async () => { + const result = await processImage( + png200x150, + [{ type: "resize", options: { width: 80, height: 60 } }], + "jpg", + ); + expect(result.info.width).toBe(80); + expect(result.info.height).toBe(60); + expect(result.info.format).toBe("jpeg"); + }); + + it("rotate then convert to WebP", async () => { + const result = await processImage( + png200x150, + [{ type: "rotate", options: { angle: 90 } }], + "webp", + ); + expect(result.info.width).toBe(150); + expect(result.info.height).toBe(200); + expect(result.info.format).toBe("webp"); + }); + + it("chain resize + rotate + grayscale + format conversion", async () => { + const result = await processImage( + png200x150, + [ + { type: "resize", options: { width: 100, height: 100 } }, + { type: "rotate", options: { angle: 180 } }, + { type: "grayscale", options: {} }, + ], + "tiff", + ); + expect(result.info.width).toBe(100); + expect(result.info.height).toBe(100); + expect(result.info.format).toBe("tiff"); + }); +}); + +// --------------------------------------------------------------------------- +// Error propagation +// --------------------------------------------------------------------------- +describe("processImage error handling", () => { + it("throws on unknown operation type", async () => { + await expect(processImage(png200x150, [{ type: "fake-op", options: {} }])).rejects.toThrow( + "Unknown operation: fake-op", + ); + }); + + it("throws on invalid image buffer", async () => { + await expect( + processImage(Buffer.from("garbage data"), [{ type: "grayscale", options: {} }]), + ).rejects.toThrow(); + }); + + it("throws on empty buffer", async () => { + await expect( + processImage(Buffer.alloc(0), [{ type: "invert", options: {} }]), + ).rejects.toThrow(); + }); + + it("propagates validation errors from operations", async () => { + await expect( + processImage(png200x150, [{ type: "resize", options: { width: -10 } }]), + ).rejects.toThrow("Resize width must be greater than 0"); + }); + + it("error in later operation aborts entire pipeline", async () => { + await expect( + processImage(png200x150, [ + { type: "resize", options: { width: 50, height: 50 } }, + { type: "brightness", options: { value: 999 } }, + ]), + ).rejects.toThrow(); + }); +}); + +// --------------------------------------------------------------------------- +// Result shape +// --------------------------------------------------------------------------- +describe("processImage result", () => { + it("returns buffer and info with correct shape", async () => { + const result = await processImage(png200x150, [ + { type: "resize", options: { width: 60, height: 40 } }, + ]); + expect(result.buffer).toBeInstanceOf(Buffer); + expect(result.buffer.length).toBeGreaterThan(0); + expect(result.info.width).toBe(60); + expect(result.info.height).toBe(40); + expect(typeof result.info.format).toBe("string"); + expect(typeof result.info.channels).toBe("number"); + expect(typeof result.info.size).toBe("number"); + expect(typeof result.info.hasAlpha).toBe("boolean"); + expect(result.info.size).toBe(result.buffer.length); + }); + + it("info.metadata contains expected keys", async () => { + const result = await processImage(png200x150, []); + expect(result.info.metadata).toBeDefined(); + expect(typeof result.info.metadata).toBe("object"); + }); +}); + +// --------------------------------------------------------------------------- +// Multi-format input processing +// --------------------------------------------------------------------------- +describe("processImage with different input formats", () => { + it("processes JPEG input", async () => { + const result = await processImage(jpg100x100, [{ type: "resize", options: { width: 50 } }]); + expect(result.info.width).toBe(50); + expect(result.buffer.length).toBeGreaterThan(0); + }); + + it("processes WebP input", async () => { + const result = await processImage(webp50x50, [{ type: "resize", options: { width: 25 } }]); + expect(result.info.width).toBe(25); + expect(result.buffer.length).toBeGreaterThan(0); + }); + + it("processes AVIF input from fixture", async () => { + const avif = readFileSync(path.join(FORMATS_DIR, "sample.avif")); + const result = await processImage(avif, [{ type: "resize", options: { width: 40 } }]); + expect(result.info.width).toBe(40); + expect(result.buffer.length).toBeGreaterThan(0); + }); + + it("processes GIF input from fixture", async () => { + const gif = readFileSync(path.join(FORMATS_DIR, "sample.gif")); + const result = await processImage(gif, [{ type: "resize", options: { width: 30 } }]); + expect(result.info.width).toBe(30); + expect(result.buffer.length).toBeGreaterThan(0); + }); + + it("processes TIFF input from fixture", async () => { + const tiff = readFileSync(path.join(FORMATS_DIR, "sample.tiff")); + const result = await processImage(tiff, [{ type: "resize", options: { width: 30 } }]); + expect(result.info.width).toBe(30); + expect(result.buffer.length).toBeGreaterThan(0); + }); +}); diff --git a/tests/unit/image-engine/metadata.test.ts b/tests/unit/image-engine/metadata.test.ts new file mode 100644 index 00000000..01e4ce22 --- /dev/null +++ b/tests/unit/image-engine/metadata.test.ts @@ -0,0 +1,360 @@ +import { readFileSync } from "node:fs"; +import { createRequire } from "node:module"; +import path from "node:path"; +import { beforeAll, describe, expect, it } from "vitest"; + +// sharp is only installed in the image-engine package, so resolve it from there +const require = createRequire( + path.resolve(__dirname, "../../../packages/image-engine/src/index.ts"), +); +const sharp = require("sharp") as typeof import("sharp").default; + +import { getImageInfo, parseExif, parseGps, parseXmp, sanitizeValue } from "@ashim/image-engine"; + +const FIXTURES_DIR = path.resolve(__dirname, "../../fixtures"); + +let png200x150: Buffer; +let jpg100x100: Buffer; +let webp50x50: Buffer; +let jpgWithExif: Buffer; + +beforeAll(() => { + png200x150 = readFileSync(path.join(FIXTURES_DIR, "test-200x150.png")); + jpg100x100 = readFileSync(path.join(FIXTURES_DIR, "test-100x100.jpg")); + webp50x50 = readFileSync(path.join(FIXTURES_DIR, "test-50x50.webp")); + jpgWithExif = readFileSync(path.join(FIXTURES_DIR, "test-with-exif.jpg")); +}); + +// --------------------------------------------------------------------------- +// getImageInfo +// --------------------------------------------------------------------------- +describe("getImageInfo", () => { + it("returns correct dimensions for PNG", async () => { + const info = await getImageInfo(png200x150); + expect(info.width).toBe(200); + expect(info.height).toBe(150); + }); + + it("returns correct format for PNG", async () => { + const info = await getImageInfo(png200x150); + expect(info.format).toBe("png"); + }); + + it("returns correct format for JPEG", async () => { + const info = await getImageInfo(jpg100x100); + expect(info.format).toBe("jpeg"); + }); + + it("returns correct dimensions for JPEG", async () => { + const info = await getImageInfo(jpg100x100); + expect(info.width).toBe(100); + expect(info.height).toBe(100); + }); + + it("returns correct format for WebP", async () => { + const info = await getImageInfo(webp50x50); + expect(info.format).toBe("webp"); + }); + + it("returns correct dimensions for WebP", async () => { + const info = await getImageInfo(webp50x50); + expect(info.width).toBe(50); + expect(info.height).toBe(50); + }); + + it("returns correct size matching buffer length", async () => { + const info = await getImageInfo(png200x150); + expect(info.size).toBe(png200x150.length); + }); + + it("returns channel count", async () => { + const info = await getImageInfo(png200x150); + expect(info.channels).toBeGreaterThanOrEqual(3); + }); + + it("returns hasAlpha boolean", async () => { + const info = await getImageInfo(png200x150); + expect(typeof info.hasAlpha).toBe("boolean"); + }); + + it("returns metadata sub-object with expected keys", async () => { + const info = await getImageInfo(png200x150); + expect(info.metadata).toBeDefined(); + expect("space" in info.metadata).toBe(true); + expect("density" in info.metadata).toBe(true); + expect("exif" in info.metadata).toBe(true); + expect("icc" in info.metadata).toBe(true); + expect("xmp" in info.metadata).toBe(true); + }); + + it("detects EXIF presence in JPEG with EXIF", async () => { + const info = await getImageInfo(jpgWithExif); + expect(info.metadata.exif).toBe(true); + }); + + it("reports no EXIF for plain PNG", async () => { + const info = await getImageInfo(png200x150); + expect(info.metadata.exif).toBe(false); + }); + + it("throws for invalid buffer", async () => { + await expect(getImageInfo(Buffer.from("not an image"))).rejects.toThrow(); + }); + + it("returns info for a dynamically created image", async () => { + const buf = await sharp({ + create: { width: 30, height: 20, channels: 4, background: "#ff000080" }, + }) + .png() + .toBuffer(); + const info = await getImageInfo(buf); + expect(info.width).toBe(30); + expect(info.height).toBe(20); + expect(info.channels).toBe(4); + expect(info.hasAlpha).toBe(true); + expect(info.format).toBe("png"); + }); +}); + +// --------------------------------------------------------------------------- +// sanitizeValue — these tests complement the ones in operations.test.ts +// --------------------------------------------------------------------------- +describe("sanitizeValue", () => { + it("converts Date to ISO string", () => { + const d = new Date("2025-06-15T12:00:00Z"); + expect(sanitizeValue(d)).toBe("2025-06-15T12:00:00.000Z"); + }); + + it("converts small Buffer to number array", () => { + const buf = Buffer.from([10, 20, 30]); + expect(sanitizeValue(buf)).toEqual([10, 20, 30]); + }); + + it("converts large Buffer (>256 bytes) to placeholder", () => { + const buf = Buffer.alloc(512, 0xab); + expect(sanitizeValue(buf)).toBe(""); + }); + + it("handles Buffer at exactly 256 bytes boundary", () => { + const buf = Buffer.alloc(256, 0xcd); + // 256 is not > 256, so it should convert to array + expect(Array.isArray(sanitizeValue(buf))).toBe(true); + }); + + it("handles Buffer at 257 bytes (just over boundary)", () => { + const buf = Buffer.alloc(257, 0xef); + expect(sanitizeValue(buf)).toBe(""); + }); + + it("recursively sanitizes arrays", () => { + const d = new Date("2025-01-01T00:00:00Z"); + expect(sanitizeValue([d, 42, "text"])).toEqual(["2025-01-01T00:00:00.000Z", 42, "text"]); + }); + + it("recursively sanitizes nested objects", () => { + const result = sanitizeValue({ + a: new Date("2025-03-01T00:00:00Z"), + b: { c: Buffer.from([1, 2]) }, + }); + expect(result).toEqual({ + a: "2025-03-01T00:00:00.000Z", + b: { c: [1, 2] }, + }); + }); + + it("passes through null", () => { + expect(sanitizeValue(null)).toBe(null); + }); + + it("passes through undefined", () => { + expect(sanitizeValue(undefined)).toBe(undefined); + }); + + it("passes through booleans", () => { + expect(sanitizeValue(true)).toBe(true); + expect(sanitizeValue(false)).toBe(false); + }); + + it("passes through numbers", () => { + expect(sanitizeValue(0)).toBe(0); + expect(sanitizeValue(3.14)).toBe(3.14); + expect(sanitizeValue(-99)).toBe(-99); + }); +}); + +// --------------------------------------------------------------------------- +// parseExif +// --------------------------------------------------------------------------- +describe("parseExif", () => { + it("parses EXIF from test fixture with metadata fields", async () => { + const metadata = await sharp(jpgWithExif).metadata(); + expect(metadata.exif).toBeTruthy(); + const result = parseExif(metadata.exif!); + expect(result.image).toBeDefined(); + expect(result.photo).toBeDefined(); + expect(result.iop).toBeDefined(); + expect(result.gps).toBeDefined(); + // Test fixture has known fields + expect(result.image.Artist).toBe("Test Artist"); + expect(result.image.Copyright).toBe("2026 Test Copyright"); + }); + + it("returns empty sections for empty buffer", () => { + const result = parseExif(Buffer.alloc(0)); + expect(result.image).toEqual({}); + expect(result.photo).toEqual({}); + expect(result.iop).toEqual({}); + expect(result.gps).toEqual({}); + }); + + it("returns empty sections for null-like buffer", () => { + // Passing a minimal buffer that won't parse as valid EXIF + const result = parseExif(Buffer.from([0x00, 0x00])); + expect(result.image).toEqual({}); + expect(result.photo).toEqual({}); + }); +}); + +// --------------------------------------------------------------------------- +// parseGps +// --------------------------------------------------------------------------- +describe("parseGps", () => { + it("parses north-east coordinates", () => { + const result = parseGps({ + GPSLatitude: [40, 44, 54], + GPSLatitudeRef: "N", + GPSLongitude: [73, 59, 8.4], + GPSLongitudeRef: "W", + }); + expect(result.latitude).toBeCloseTo(40.7483, 3); + expect(result.longitude).toBeCloseTo(-73.9857, 3); + expect(result.altitude).toBeNull(); + }); + + it("parses southern hemisphere with altitude", () => { + const result = parseGps({ + GPSLatitude: [33, 51, 54], + GPSLatitudeRef: "S", + GPSLongitude: [151, 12, 36], + GPSLongitudeRef: "E", + GPSAltitude: 25, + GPSAltitudeRef: 0, + }); + expect(result.latitude).toBeCloseTo(-33.865, 2); + expect(result.longitude).toBeCloseTo(151.21, 2); + expect(result.altitude).toBe(25); + }); + + it("returns negative altitude for below sea level", () => { + const result = parseGps({ + GPSLatitude: [31, 30, 0], + GPSLatitudeRef: "N", + GPSLongitude: [35, 28, 0], + GPSLongitudeRef: "E", + GPSAltitude: 400, + GPSAltitudeRef: 1, // below sea level + }); + expect(result.altitude).toBe(-400); + }); + + it("returns nulls for empty GPS data", () => { + const result = parseGps({}); + expect(result.latitude).toBeNull(); + expect(result.longitude).toBeNull(); + expect(result.altitude).toBeNull(); + }); + + it("handles missing longitude", () => { + const result = parseGps({ + GPSLatitude: [51, 30, 0], + GPSLatitudeRef: "N", + }); + expect(result.latitude).toBeCloseTo(51.5, 1); + expect(result.longitude).toBeNull(); + }); + + it("handles missing latitude", () => { + const result = parseGps({ + GPSLongitude: [0, 7, 39.6], + GPSLongitudeRef: "W", + }); + expect(result.latitude).toBeNull(); + expect(result.longitude).toBeCloseTo(-0.1277, 3); + }); + + it("ignores invalid (NaN) GPS values", () => { + const result = parseGps({ + GPSLatitude: [NaN, 30, 0], + GPSLatitudeRef: "N", + GPSLongitude: [0, NaN, 39.6], + GPSLongitudeRef: "W", + }); + expect(result.latitude).toBeNull(); + expect(result.longitude).toBeNull(); + }); + + it("ignores wrong-length GPS arrays", () => { + const result = parseGps({ + GPSLatitude: [51, 30], + GPSLatitudeRef: "N", + }); + expect(result.latitude).toBeNull(); + }); +}); + +// --------------------------------------------------------------------------- +// parseXmp +// --------------------------------------------------------------------------- +describe("parseXmp", () => { + it("extracts key-value pairs from XMP XML", () => { + const xml = Buffer.from( + '' + + '' + + '' + + "", + ); + const result = parseXmp(xml); + expect(result["dc:creator"]).toBe("Bob"); + expect(result["dc:title"]).toBe("Sunset"); + }); + + it("filters out xmlns: namespace declarations", () => { + const xml = Buffer.from( + '' + + '' + + "", + ); + const result = parseXmp(xml); + expect(result["xmlns:x"]).toBeUndefined(); + expect(result["xmlns:dc"]).toBeUndefined(); + expect(result["dc:format"]).toBe("image/png"); + }); + + it("filters out rdf: prefixed attributes", () => { + const xml = Buffer.from(''); + const result = parseXmp(xml); + expect(result["rdf:about"]).toBeUndefined(); + expect(result["dc:subject"]).toBe("test"); + }); + + it("returns empty object for empty buffer", () => { + expect(parseXmp(Buffer.alloc(0))).toEqual({}); + }); + + it("returns empty object for non-XML content", () => { + expect(parseXmp(Buffer.from("hello world"))).toEqual({}); + }); + + it("extracts multiple namespaced attributes", () => { + const xml = Buffer.from( + "', + ); + const result = parseXmp(xml); + expect(result["xmp:CreateDate"]).toBe("2025-01-01"); + expect(result["xmp:ModifyDate"]).toBe("2025-06-01"); + expect(result["photoshop:ColorMode"]).toBe("3"); + }); +}); diff --git a/tests/unit/image-engine/mime.test.ts b/tests/unit/image-engine/mime.test.ts new file mode 100644 index 00000000..135c3914 --- /dev/null +++ b/tests/unit/image-engine/mime.test.ts @@ -0,0 +1,174 @@ +import { extToMime, formatToExt, formatToMime, mimeToExt } from "@ashim/image-engine"; +import { describe, expect, it } from "vitest"; + +// --------------------------------------------------------------------------- +// extToMime — file extension to MIME type +// --------------------------------------------------------------------------- +describe("extToMime", () => { + const cases: Array<[string, string]> = [ + ["jpg", "image/jpeg"], + ["jpeg", "image/jpeg"], + ["png", "image/png"], + ["webp", "image/webp"], + ["avif", "image/avif"], + ["tiff", "image/tiff"], + ["tif", "image/tiff"], + ["gif", "image/gif"], + ["bmp", "image/bmp"], + ["svg", "image/svg+xml"], + ["ico", "image/x-icon"], + ["heif", "image/heif"], + ["heic", "image/heic"], + ["jxl", "image/jxl"], + ["dng", "image/x-adobe-dng"], + ["cr2", "image/x-canon-cr2"], + ["nef", "image/x-nikon-nef"], + ["arw", "image/x-sony-arw"], + ["orf", "image/x-olympus-orf"], + ["rw2", "image/x-panasonic-rw2"], + ["tga", "image/x-tga"], + ["psd", "image/vnd.adobe.photoshop"], + ["exr", "image/x-exr"], + ["hdr", "image/vnd.radiance"], + ]; + + for (const [ext, mime] of cases) { + it(`maps "${ext}" to "${mime}"`, () => { + expect(extToMime(ext)).toBe(mime); + }); + } + + it("normalizes uppercase extensions", () => { + expect(extToMime("PNG")).toBe("image/png"); + expect(extToMime("JPG")).toBe("image/jpeg"); + }); + + it("strips leading dot from extension", () => { + expect(extToMime(".png")).toBe("image/png"); + expect(extToMime(".jpg")).toBe("image/jpeg"); + }); + + it("returns application/octet-stream for unknown extension", () => { + expect(extToMime("xyz")).toBe("application/octet-stream"); + expect(extToMime("")).toBe("application/octet-stream"); + }); +}); + +// --------------------------------------------------------------------------- +// mimeToExt — MIME type to file extension +// --------------------------------------------------------------------------- +describe("mimeToExt", () => { + const cases: Array<[string, string]> = [ + ["image/jpeg", "jpg"], + ["image/png", "png"], + ["image/webp", "webp"], + ["image/avif", "avif"], + ["image/tiff", "tiff"], + ["image/gif", "gif"], + ["image/bmp", "bmp"], + ["image/svg+xml", "svg"], + ["image/x-icon", "ico"], + ["image/heif", "heif"], + ["image/heic", "heic"], + ["image/jxl", "jxl"], + ["image/x-adobe-dng", "dng"], + ["image/x-canon-cr2", "cr2"], + ["image/x-nikon-nef", "nef"], + ["image/x-sony-arw", "arw"], + ["image/x-olympus-orf", "orf"], + ["image/x-panasonic-rw2", "rw2"], + ["image/x-tga", "tga"], + ["image/vnd.adobe.photoshop", "psd"], + ["image/x-exr", "exr"], + ["image/vnd.radiance", "hdr"], + ]; + + for (const [mime, ext] of cases) { + it(`maps "${mime}" to "${ext}"`, () => { + expect(mimeToExt(mime)).toBe(ext); + }); + } + + it("normalizes uppercase MIME types", () => { + expect(mimeToExt("IMAGE/JPEG")).toBe("jpg"); + expect(mimeToExt("Image/PNG")).toBe("png"); + }); + + it("returns 'bin' for unknown MIME type", () => { + expect(mimeToExt("application/pdf")).toBe("bin"); + expect(mimeToExt("text/plain")).toBe("bin"); + expect(mimeToExt("")).toBe("bin"); + }); +}); + +// --------------------------------------------------------------------------- +// formatToMime — Sharp format string to MIME type +// --------------------------------------------------------------------------- +describe("formatToMime", () => { + it("maps 'jpeg' (Sharp format) to 'image/jpeg'", () => { + expect(formatToMime("jpeg")).toBe("image/jpeg"); + }); + + it("maps 'png' to 'image/png'", () => { + expect(formatToMime("png")).toBe("image/png"); + }); + + it("maps 'webp' to 'image/webp'", () => { + expect(formatToMime("webp")).toBe("image/webp"); + }); + + it("maps 'gif' to 'image/gif'", () => { + expect(formatToMime("gif")).toBe("image/gif"); + }); + + it("maps 'tiff' to 'image/tiff'", () => { + expect(formatToMime("tiff")).toBe("image/tiff"); + }); + + it("maps 'avif' to 'image/avif'", () => { + expect(formatToMime("avif")).toBe("image/avif"); + }); + + it("maps 'svg' to 'image/svg+xml'", () => { + expect(formatToMime("svg")).toBe("image/svg+xml"); + }); + + it("returns application/octet-stream for unknown format", () => { + expect(formatToMime("unknown")).toBe("application/octet-stream"); + }); +}); + +// --------------------------------------------------------------------------- +// formatToExt — Sharp format string to file extension +// --------------------------------------------------------------------------- +describe("formatToExt", () => { + it("maps 'jpeg' to 'jpg'", () => { + expect(formatToExt("jpeg")).toBe("jpg"); + }); + + it("maps 'png' to 'png' (identity)", () => { + expect(formatToExt("png")).toBe("png"); + }); + + it("maps 'webp' to 'webp' (identity)", () => { + expect(formatToExt("webp")).toBe("webp"); + }); + + it("maps 'gif' to 'gif' (identity)", () => { + expect(formatToExt("gif")).toBe("gif"); + }); + + it("maps 'tiff' to 'tiff' (identity)", () => { + expect(formatToExt("tiff")).toBe("tiff"); + }); + + it("normalizes case", () => { + expect(formatToExt("JPEG")).toBe("jpg"); + expect(formatToExt("PNG")).toBe("png"); + }); + + it("returns the format itself for unknown formats", () => { + expect(formatToExt("bmp")).toBe("bmp"); + expect(formatToExt("xyz")).toBe("xyz"); + }); +}); diff --git a/tests/unit/web/lib-helpers.test.ts b/tests/unit/web/lib-helpers.test.ts new file mode 100644 index 00000000..7339720e --- /dev/null +++ b/tests/unit/web/lib-helpers.test.ts @@ -0,0 +1,327 @@ +// @vitest-environment jsdom +import { describe, expect, it, vi } from "vitest"; + +// --------------------------------------------------------------------------- +// Global mocks +// --------------------------------------------------------------------------- + +vi.stubGlobal("localStorage", { + getItem: vi.fn(() => null), + setItem: vi.fn(), + removeItem: vi.fn(), + clear: vi.fn(), + get length() { + return 0; + }, + key: vi.fn(() => null), +}); + +// ========================================================================== +// formatFileSize (download.ts) +// ========================================================================== + +import { formatFileSize } from "@/lib/download"; + +describe("formatFileSize", () => { + it("formats bytes under 1 MB as KB", () => { + expect(formatFileSize(512)).toBe("1 KB"); + expect(formatFileSize(1024)).toBe("1 KB"); + expect(formatFileSize(10240)).toBe("10 KB"); + expect(formatFileSize(512000)).toBe("500 KB"); + }); + + it("formats bytes at or above 1 MB as MB", () => { + expect(formatFileSize(1048576)).toBe("1.0 MB"); + expect(formatFileSize(1572864)).toBe("1.5 MB"); + expect(formatFileSize(10485760)).toBe("10.0 MB"); + }); + + it("handles zero bytes", () => { + expect(formatFileSize(0)).toBe("0 KB"); + }); + + it("formats values just below 1 MB threshold", () => { + expect(formatFileSize(1048575)).toBe("1024 KB"); + }); +}); + +// ========================================================================== +// formatExifValue / exifStr (metadata-utils.ts) +// ========================================================================== + +import { exifStr, formatExifValue, SKIP_KEYS } from "@/lib/metadata-utils"; + +describe("formatExifValue", () => { + it("returns 'N/A' for null", () => { + expect(formatExifValue("any", null)).toBe("N/A"); + }); + + it("returns 'N/A' for undefined", () => { + expect(formatExifValue("any", undefined)).toBe("N/A"); + }); + + it("returns string values as-is", () => { + expect(formatExifValue("Make", "Canon")).toBe("Canon"); + }); + + it("formats ExposureTime as fraction", () => { + expect(formatExifValue("ExposureTime", 0.004)).toBe("1/250s"); + expect(formatExifValue("ExposureTime", 0.0125)).toBe("1/80s"); + }); + + it("formats ExposureTime >= 1 as plain number", () => { + expect(formatExifValue("ExposureTime", 2)).toBe("2"); + }); + + it("formats FNumber with f/ prefix", () => { + expect(formatExifValue("FNumber", 2.8)).toBe("f/2.8"); + }); + + it("formats FocalLength with mm suffix", () => { + expect(formatExifValue("FocalLength", 50)).toBe("50mm"); + expect(formatExifValue("FocalLengthIn35mmFormat", 85)).toBe("85mm"); + }); + + it("returns plain number string for other numeric keys", () => { + expect(formatExifValue("ISO", 400)).toBe("400"); + }); + + it("joins short arrays with commas", () => { + expect(formatExifValue("Keywords", ["nature", "sunset"])).toBe("nature, sunset"); + }); + + it("summarizes long arrays", () => { + const arr = [1, 2, 3, 4, 5, 6, 7]; + expect(formatExifValue("SomeField", arr)).toBe("[7 values]"); + }); + + it("handles arrays of exactly 6 items", () => { + const arr = [1, 2, 3, 4, 5, 6]; + expect(formatExifValue("SomeField", arr)).toBe("1, 2, 3, 4, 5, 6"); + }); + + it("stringifies other types", () => { + expect(formatExifValue("unknown", true)).toBe("true"); + expect(formatExifValue("unknown", { nested: 1 })).toBe("[object Object]"); + }); +}); + +describe("exifStr", () => { + it("returns string value from exif object", () => { + expect(exifStr({ Make: "Nikon" }, "Make")).toBe("Nikon"); + }); + + it("returns stringified number from exif object", () => { + expect(exifStr({ ISO: 800 }, "ISO")).toBe("800"); + }); + + it("returns empty string for missing key", () => { + expect(exifStr({ Make: "Canon" }, "Model")).toBe(""); + }); + + it("returns empty string for null exif", () => { + expect(exifStr(null, "Make")).toBe(""); + }); + + it("returns empty string for undefined exif", () => { + expect(exifStr(undefined, "Make")).toBe(""); + }); + + it("returns empty string for non-string/number values", () => { + expect(exifStr({ Keywords: ["a", "b"] }, "Keywords")).toBe(""); + }); +}); + +describe("SKIP_KEYS", () => { + it("contains expected internal keys", () => { + expect(SKIP_KEYS.has("ExifToolVersion")).toBe(true); + expect(SKIP_KEYS.has("FileName")).toBe(true); + expect(SKIP_KEYS.has("ThumbnailImage")).toBe(true); + expect(SKIP_KEYS.has("MakerNote")).toBe(true); + }); + + it("does not contain display keys", () => { + expect(SKIP_KEYS.has("Make")).toBe(false); + expect(SKIP_KEYS.has("Model")).toBe(false); + expect(SKIP_KEYS.has("ISO")).toBe(false); + }); +}); + +// ========================================================================== +// getSuggestedTools (suggested-tools.ts) +// ========================================================================== + +import { getSuggestedTools } from "@/lib/suggested-tools"; + +describe("getSuggestedTools", () => { + it("returns suggestions for a known tool", () => { + const suggestions = getSuggestedTools("resize"); + expect(suggestions).toContain("compress"); + expect(suggestions).toContain("convert"); + expect(suggestions.length).toBeGreaterThan(0); + }); + + it("returns suggestions for compress", () => { + const suggestions = getSuggestedTools("compress"); + expect(suggestions).toContain("convert"); + }); + + it("returns default fallback for an unknown tool", () => { + const suggestions = getSuggestedTools("nonexistent-tool"); + expect(suggestions).toEqual(["resize", "compress", "convert"]); + }); + + it("returns suggestions for remove-background", () => { + const suggestions = getSuggestedTools("remove-background"); + expect(suggestions).toContain("resize"); + expect(suggestions).toContain("compress"); + }); +}); + +// ========================================================================== +// needsServerPreview (image-preview.ts) +// ========================================================================== + +import { needsServerPreview } from "@/lib/image-preview"; + +describe("needsServerPreview", () => { + it("returns true for HEIC files", () => { + const file = new File([], "photo.heic", { type: "image/heic" }); + expect(needsServerPreview(file)).toBe(true); + }); + + it("returns true for HEIF files", () => { + const file = new File([], "photo.heif", { type: "image/heif" }); + expect(needsServerPreview(file)).toBe(true); + }); + + it("returns true for JPEG XL files", () => { + const file = new File([], "photo.jxl", { type: "image/jxl" }); + expect(needsServerPreview(file)).toBe(true); + }); + + it("returns true for camera RAW formats", () => { + for (const ext of ["dng", "cr2", "nef", "arw", "orf", "rw2"]) { + const file = new File([], `photo.${ext}`); + expect(needsServerPreview(file)).toBe(true); + } + }); + + it("returns true for PSD files", () => { + const file = new File([], "design.psd"); + expect(needsServerPreview(file)).toBe(true); + }); + + it("returns true for EXR files", () => { + const file = new File([], "render.exr"); + expect(needsServerPreview(file)).toBe(true); + }); + + it("returns true for HDR files", () => { + const file = new File([], "panorama.hdr"); + expect(needsServerPreview(file)).toBe(true); + }); + + it("returns true for TGA files", () => { + const file = new File([], "texture.tga"); + expect(needsServerPreview(file)).toBe(true); + }); + + it("returns true for ICO files", () => { + const file = new File([], "favicon.ico"); + expect(needsServerPreview(file)).toBe(true); + }); + + it("returns false for standard browser-supported formats", () => { + for (const ext of ["png", "jpg", "jpeg", "gif", "webp", "svg", "bmp"]) { + const file = new File([], `image.${ext}`); + expect(needsServerPreview(file)).toBe(false); + } + }); + + it("returns false for files with no extension", () => { + const file = new File([], "noext"); + expect(needsServerPreview(file)).toBe(false); + }); +}); + +// ========================================================================== +// Collage template helpers (collage-templates.ts) +// ========================================================================== + +import { + COLLAGE_TEMPLATES, + getDefaultTemplate, + getTemplateById, + getTemplatesForCount, +} from "@/lib/collage-templates"; + +describe("getTemplatesForCount", () => { + it("returns templates matching a given image count", () => { + const twoImage = getTemplatesForCount(2); + expect(twoImage.length).toBeGreaterThan(0); + expect(twoImage.every((t) => t.imageCount === 2)).toBe(true); + }); + + it("returns multiple templates for counts with variants", () => { + const threeImage = getTemplatesForCount(3); + expect(threeImage.length).toBeGreaterThanOrEqual(3); + }); + + it("returns empty array for unsupported count", () => { + expect(getTemplatesForCount(100)).toEqual([]); + }); +}); + +describe("getDefaultTemplate", () => { + it("returns first exact match for known count", () => { + const template = getDefaultTemplate(2); + expect(template.imageCount).toBe(2); + expect(template.id).toBe("2-h-equal"); + }); + + it("returns nearest template for unknown count", () => { + const template = getDefaultTemplate(10); + expect(template).toBeDefined(); + expect(template.cells.length).toBeGreaterThan(0); + }); + + it("returns a template for count 1", () => { + const template = getDefaultTemplate(1); + expect(template).toBeDefined(); + }); +}); + +describe("getTemplateById", () => { + it("finds a template by ID", () => { + const template = getTemplateById("4-grid"); + expect(template).toBeDefined(); + expect(template?.imageCount).toBe(4); + expect(template?.cells).toHaveLength(4); + }); + + it("returns undefined for unknown ID", () => { + expect(getTemplateById("nonexistent")).toBeUndefined(); + }); +}); + +describe("COLLAGE_TEMPLATES", () => { + it("has templates for counts 2 through 9", () => { + for (let count = 2; count <= 9; count++) { + const templates = getTemplatesForCount(count); + expect(templates.length).toBeGreaterThanOrEqual(1); + } + }); + + it("every template has cells matching or exceeding imageCount", () => { + for (const t of COLLAGE_TEMPLATES) { + expect(t.cells.length).toBeGreaterThanOrEqual(t.imageCount); + } + }); + + it("every template has a non-empty label", () => { + for (const t of COLLAGE_TEMPLATES) { + expect(t.label.length).toBeGreaterThan(0); + } + }); +}); diff --git a/tests/unit/web/zustand-stores.test.ts b/tests/unit/web/zustand-stores.test.ts new file mode 100644 index 00000000..a1e8e3f1 --- /dev/null +++ b/tests/unit/web/zustand-stores.test.ts @@ -0,0 +1,1830 @@ +// @vitest-environment jsdom +import { beforeEach, describe, expect, it, vi } from "vitest"; + +// Bypass zustand persist middleware — it captures window.localStorage at import +// time before jsdom's storage is reliably available in the vitest fork pool. +vi.mock("zustand/middleware", async (importOriginal) => { + const actual: Record = await importOriginal(); + return { + ...actual, + persist: (config: unknown) => config, + }; +}); + +// --------------------------------------------------------------------------- +// Global mocks +// --------------------------------------------------------------------------- + +const revokeObjectURL = vi.fn(); +const createObjectURL = vi.fn((_obj: Blob | MediaSource) => "blob:fake-url"); + +vi.stubGlobal("URL", { + ...globalThis.URL, + createObjectURL, + revokeObjectURL, +}); + +const fetchMock = vi.fn(); +vi.stubGlobal("fetch", fetchMock); + +const storageMap = new Map(); +vi.stubGlobal("localStorage", { + getItem: vi.fn((key: string) => storageMap.get(key) ?? null), + setItem: vi.fn((key: string, val: string) => storageMap.set(key, val)), + removeItem: vi.fn((key: string) => storageMap.delete(key)), + clear: vi.fn(() => storageMap.clear()), + get length() { + return storageMap.size; + }, + key: vi.fn((_i: number) => null), +}); + +// Mock matchMedia for theme store +vi.stubGlobal( + "matchMedia", + vi.fn().mockReturnValue({ + matches: false, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + }), +); + +// ========================================================================== +// Base64Store +// ========================================================================== + +import type { Base64Error, Base64Result } from "@/stores/base64-store"; +import { useBase64Store } from "@/stores/base64-store"; + +describe("useBase64Store", () => { + beforeEach(() => { + useBase64Store.getState().reset(); + }); + + it("has correct initial state", () => { + const s = useBase64Store.getState(); + expect(s.results).toEqual([]); + expect(s.errors).toEqual([]); + expect(s.processing).toBe(false); + expect(s.progress).toBeNull(); + expect(s.expandedIndex).toBe(0); + }); + + it("setResults stores results and errors, resets expandedIndex", () => { + useBase64Store.getState().setExpandedIndex(3); + const results: Base64Result[] = [ + { + filename: "a.png", + mimeType: "image/png", + width: 100, + height: 100, + originalSize: 1024, + encodedSize: 1365, + overheadPercent: 33.3, + base64: "abc", + dataUri: "data:image/png;base64,abc", + }, + ]; + const errors: Base64Error[] = [{ filename: "b.png", error: "too big" }]; + useBase64Store.getState().setResults(results, errors); + + const s = useBase64Store.getState(); + expect(s.results).toEqual(results); + expect(s.errors).toEqual(errors); + expect(s.expandedIndex).toBe(0); + }); + + it("setProcessing updates processing flag", () => { + useBase64Store.getState().setProcessing(true); + expect(useBase64Store.getState().processing).toBe(true); + useBase64Store.getState().setProcessing(false); + expect(useBase64Store.getState().processing).toBe(false); + }); + + it("setProgress updates progress", () => { + const progress = { completed: 2, total: 5, currentFile: "c.png" }; + useBase64Store.getState().setProgress(progress); + expect(useBase64Store.getState().progress).toEqual(progress); + + useBase64Store.getState().setProgress(null); + expect(useBase64Store.getState().progress).toBeNull(); + }); + + it("addResult appends to results array", () => { + const r1: Base64Result = { + filename: "a.png", + mimeType: "image/png", + width: 10, + height: 10, + originalSize: 100, + encodedSize: 133, + overheadPercent: 33, + base64: "x", + dataUri: "data:image/png;base64,x", + }; + const r2: Base64Result = { ...r1, filename: "b.png" }; + + useBase64Store.getState().addResult(r1); + expect(useBase64Store.getState().results).toHaveLength(1); + + useBase64Store.getState().addResult(r2); + expect(useBase64Store.getState().results).toHaveLength(2); + expect(useBase64Store.getState().results[1].filename).toBe("b.png"); + }); + + it("addError appends to errors array", () => { + useBase64Store.getState().addError({ filename: "x.png", error: "fail" }); + useBase64Store.getState().addError({ filename: "y.png", error: "crash" }); + expect(useBase64Store.getState().errors).toHaveLength(2); + }); + + it("setExpandedIndex updates expanded index", () => { + useBase64Store.getState().setExpandedIndex(5); + expect(useBase64Store.getState().expandedIndex).toBe(5); + }); + + it("reset clears all state to initial values", () => { + useBase64Store.getState().setProcessing(true); + useBase64Store.getState().setProgress({ completed: 1, total: 2, currentFile: "f" }); + useBase64Store.getState().addResult({ + filename: "a.png", + mimeType: "image/png", + width: 1, + height: 1, + originalSize: 1, + encodedSize: 1, + overheadPercent: 0, + base64: "", + dataUri: "", + }); + useBase64Store.getState().addError({ filename: "b.png", error: "err" }); + useBase64Store.getState().setExpandedIndex(2); + + useBase64Store.getState().reset(); + const s = useBase64Store.getState(); + expect(s.results).toEqual([]); + expect(s.errors).toEqual([]); + expect(s.processing).toBe(false); + expect(s.progress).toBeNull(); + expect(s.expandedIndex).toBe(0); + }); +}); + +// ========================================================================== +// DuplicateStore +// ========================================================================== + +import type { DuplicateResult } from "@/stores/duplicate-store"; +import { useDuplicateStore } from "@/stores/duplicate-store"; + +describe("useDuplicateStore", () => { + beforeEach(() => { + useDuplicateStore.getState().reset(); + }); + + it("has correct initial state", () => { + const s = useDuplicateStore.getState(); + expect(s.results).toBeNull(); + expect(s.scanning).toBe(false); + expect(s.viewMode).toBe("overview"); + expect(s.selectedGroupIndex).toBe(0); + expect(s.bestOverrides).toEqual({}); + }); + + it("setResults stores result and resets view state", () => { + useDuplicateStore.getState().setViewMode("detail"); + useDuplicateStore.getState().setSelectedGroup(3); + useDuplicateStore.getState().overrideBest(0, 2); + + const result: DuplicateResult = { + totalImages: 10, + uniqueImages: 7, + spaceSaveable: 1024, + duplicateGroups: [ + { + groupId: 1, + files: [ + { + filename: "a.png", + similarity: 0.99, + width: 100, + height: 100, + fileSize: 512, + format: "png", + isBest: true, + thumbnail: null, + }, + ], + }, + ], + }; + useDuplicateStore.getState().setResults(result); + + const s = useDuplicateStore.getState(); + expect(s.results).toEqual(result); + expect(s.viewMode).toBe("overview"); + expect(s.selectedGroupIndex).toBe(0); + expect(s.bestOverrides).toEqual({}); + }); + + it("setResults with null clears results", () => { + useDuplicateStore.getState().setResults({ + totalImages: 1, + uniqueImages: 1, + spaceSaveable: 0, + duplicateGroups: [], + }); + useDuplicateStore.getState().setResults(null); + expect(useDuplicateStore.getState().results).toBeNull(); + }); + + it("setScanning updates scanning flag", () => { + useDuplicateStore.getState().setScanning(true); + expect(useDuplicateStore.getState().scanning).toBe(true); + }); + + it("setViewMode changes view mode", () => { + useDuplicateStore.getState().setViewMode("detail"); + expect(useDuplicateStore.getState().viewMode).toBe("detail"); + }); + + it("setSelectedGroup sets index and switches to detail view", () => { + useDuplicateStore.getState().setSelectedGroup(2); + expect(useDuplicateStore.getState().selectedGroupIndex).toBe(2); + expect(useDuplicateStore.getState().viewMode).toBe("detail"); + }); + + it("overrideBest accumulates overrides per group", () => { + useDuplicateStore.getState().overrideBest(0, 1); + useDuplicateStore.getState().overrideBest(2, 3); + const overrides = useDuplicateStore.getState().bestOverrides; + expect(overrides[0]).toBe(1); + expect(overrides[2]).toBe(3); + }); + + it("overrideBest replaces previous override for same group", () => { + useDuplicateStore.getState().overrideBest(0, 1); + useDuplicateStore.getState().overrideBest(0, 2); + expect(useDuplicateStore.getState().bestOverrides[0]).toBe(2); + }); + + it("reset clears everything", () => { + useDuplicateStore.getState().setScanning(true); + useDuplicateStore.getState().setViewMode("detail"); + useDuplicateStore.getState().setSelectedGroup(5); + useDuplicateStore.getState().overrideBest(1, 2); + + useDuplicateStore.getState().reset(); + const s = useDuplicateStore.getState(); + expect(s.results).toBeNull(); + expect(s.scanning).toBe(false); + expect(s.viewMode).toBe("overview"); + expect(s.selectedGroupIndex).toBe(0); + expect(s.bestOverrides).toEqual({}); + }); +}); + +// ========================================================================== +// PipelineStore +// ========================================================================== + +import { usePipelineStore } from "@/stores/pipeline-store"; + +describe("usePipelineStore", () => { + beforeEach(() => { + usePipelineStore.getState().reset(); + }); + + it("has correct initial state", () => { + const s = usePipelineStore.getState(); + expect(s.steps).toEqual([]); + expect(s.expandedStepId).toBeNull(); + expect(s.savedPipelines).toEqual([]); + }); + + it("addStep adds a step with generated ID and expands it", () => { + usePipelineStore.getState().addStep("resize"); + const s = usePipelineStore.getState(); + expect(s.steps).toHaveLength(1); + expect(s.steps[0].toolId).toBe("resize"); + expect(s.steps[0].settings).toEqual({}); + expect(s.steps[0].id).toBeTruthy(); + expect(s.expandedStepId).toBe(s.steps[0].id); + }); + + it("addStep appends additional steps", () => { + usePipelineStore.getState().addStep("resize"); + usePipelineStore.getState().addStep("compress"); + const s = usePipelineStore.getState(); + expect(s.steps).toHaveLength(2); + expect(s.steps[0].toolId).toBe("resize"); + expect(s.steps[1].toolId).toBe("compress"); + expect(s.expandedStepId).toBe(s.steps[1].id); + }); + + it("removeStep removes the step by id", () => { + usePipelineStore.getState().addStep("resize"); + usePipelineStore.getState().addStep("compress"); + const firstId = usePipelineStore.getState().steps[0].id; + usePipelineStore.getState().removeStep(firstId); + expect(usePipelineStore.getState().steps).toHaveLength(1); + expect(usePipelineStore.getState().steps[0].toolId).toBe("compress"); + }); + + it("removeStep clears expandedStepId if removed step was expanded", () => { + usePipelineStore.getState().addStep("resize"); + const id = usePipelineStore.getState().steps[0].id; + expect(usePipelineStore.getState().expandedStepId).toBe(id); + + usePipelineStore.getState().removeStep(id); + expect(usePipelineStore.getState().expandedStepId).toBeNull(); + }); + + it("removeStep preserves expandedStepId if different step removed", () => { + usePipelineStore.getState().addStep("resize"); + usePipelineStore.getState().addStep("compress"); + const compressId = usePipelineStore.getState().steps[1].id; + const resizeId = usePipelineStore.getState().steps[0].id; + usePipelineStore.getState().setExpandedStep(compressId); + + usePipelineStore.getState().removeStep(resizeId); + expect(usePipelineStore.getState().expandedStepId).toBe(compressId); + }); + + it("reorderSteps swaps two steps", () => { + usePipelineStore.getState().addStep("resize"); + usePipelineStore.getState().addStep("compress"); + usePipelineStore.getState().addStep("convert"); + const ids = usePipelineStore.getState().steps.map((s) => s.id); + + usePipelineStore.getState().reorderSteps(ids[0], ids[2]); + const steps = usePipelineStore.getState().steps; + expect(steps[0].toolId).toBe("compress"); + expect(steps[1].toolId).toBe("convert"); + expect(steps[2].toolId).toBe("resize"); + }); + + it("reorderSteps no-ops for invalid ids", () => { + usePipelineStore.getState().addStep("resize"); + usePipelineStore.getState().reorderSteps("nonexistent", "also-nonexistent"); + expect(usePipelineStore.getState().steps).toHaveLength(1); + }); + + it("updateStepSettings merges settings into the step", () => { + usePipelineStore.getState().addStep("resize"); + const id = usePipelineStore.getState().steps[0].id; + usePipelineStore.getState().updateStepSettings(id, { width: 800, height: 600 }); + expect(usePipelineStore.getState().steps[0].settings).toEqual({ width: 800, height: 600 }); + }); + + it("updateStepSettings replaces settings entirely", () => { + usePipelineStore.getState().addStep("resize"); + const id = usePipelineStore.getState().steps[0].id; + usePipelineStore.getState().updateStepSettings(id, { width: 800 }); + usePipelineStore.getState().updateStepSettings(id, { height: 600 }); + expect(usePipelineStore.getState().steps[0].settings).toEqual({ height: 600 }); + }); + + it("setExpandedStep updates the expanded step", () => { + usePipelineStore.getState().setExpandedStep("some-id"); + expect(usePipelineStore.getState().expandedStepId).toBe("some-id"); + usePipelineStore.getState().setExpandedStep(null); + expect(usePipelineStore.getState().expandedStepId).toBeNull(); + }); + + it("loadSteps replaces all steps with new ones and clears expanded", () => { + usePipelineStore.getState().addStep("resize"); + usePipelineStore.getState().loadSteps([ + { toolId: "compress", settings: { quality: 80 } }, + { toolId: "convert", settings: { format: "webp" } }, + ]); + + const s = usePipelineStore.getState(); + expect(s.steps).toHaveLength(2); + expect(s.steps[0].toolId).toBe("compress"); + expect(s.steps[0].settings).toEqual({ quality: 80 }); + expect(s.steps[1].toolId).toBe("convert"); + expect(s.expandedStepId).toBeNull(); + }); + + it("setSavedPipelines stores the pipeline list", () => { + const pipelines = [ + { + id: "p1", + name: "My Pipeline", + description: "Test", + steps: [{ toolId: "resize", settings: {} }], + createdAt: "2024-01-01", + }, + ]; + usePipelineStore.getState().setSavedPipelines(pipelines); + expect(usePipelineStore.getState().savedPipelines).toEqual(pipelines); + }); + + it("reset clears all state", () => { + usePipelineStore.getState().addStep("resize"); + usePipelineStore.getState().setSavedPipelines([ + { + id: "p1", + name: "P", + description: null, + steps: [], + createdAt: "", + }, + ]); + + usePipelineStore.getState().reset(); + const s = usePipelineStore.getState(); + expect(s.steps).toEqual([]); + expect(s.expandedStepId).toBeNull(); + expect(s.savedPipelines).toEqual([]); + }); +}); + +// ========================================================================== +// QrStore +// ========================================================================== + +import { encodeQrData, useQrStore } from "@/stores/qr-store"; + +describe("useQrStore", () => { + beforeEach(() => { + useQrStore.getState().reset(); + }); + + it("has correct initial state", () => { + const s = useQrStore.getState(); + expect(s.contentType).toBe("url"); + expect(s.textData).toBe(""); + expect(s.dotType).toBe("rounded"); + expect(s.dotColor).toBe("#000000"); + expect(s.bgColor).toBe("#FFFFFF"); + expect(s.bgTransparent).toBe(false); + expect(s.size).toBe(1000); + expect(s.errorCorrection).toBe("Q"); + expect(s.downloadFormat).toBe("png"); + expect(s.logoFile).toBeNull(); + expect(s.logoDataUrl).toBeNull(); + expect(s.logoSize).toBe(0.4); + expect(s.hideBackgroundDots).toBe(true); + expect(s.wifiData).toEqual({ + ssid: "", + password: "", + encryption: "WPA", + hidden: false, + }); + }); + + it("setContentType changes content type", () => { + useQrStore.getState().setContentType("wifi"); + expect(useQrStore.getState().contentType).toBe("wifi"); + }); + + it("setTextData updates text data", () => { + useQrStore.getState().setTextData("https://example.com"); + expect(useQrStore.getState().textData).toBe("https://example.com"); + }); + + it("setWifiData merges partial wifi data", () => { + useQrStore.getState().setWifiData({ ssid: "MyNetwork" }); + const wifi = useQrStore.getState().wifiData; + expect(wifi.ssid).toBe("MyNetwork"); + expect(wifi.encryption).toBe("WPA"); // unchanged + + useQrStore.getState().setWifiData({ password: "secret123" }); + expect(useQrStore.getState().wifiData.ssid).toBe("MyNetwork"); + expect(useQrStore.getState().wifiData.password).toBe("secret123"); + }); + + it("setVcardData merges partial vcard data", () => { + useQrStore.getState().setVcardData({ firstName: "John", lastName: "Doe" }); + const vcard = useQrStore.getState().vcardData; + expect(vcard.firstName).toBe("John"); + expect(vcard.lastName).toBe("Doe"); + expect(vcard.phone).toBe(""); // unchanged + }); + + it("setEmailData merges partial email data", () => { + useQrStore.getState().setEmailData({ to: "a@b.com", subject: "Hi" }); + expect(useQrStore.getState().emailData.to).toBe("a@b.com"); + expect(useQrStore.getState().emailData.subject).toBe("Hi"); + expect(useQrStore.getState().emailData.body).toBe(""); // unchanged + }); + + it("setSmsData merges partial sms data", () => { + useQrStore.getState().setSmsData({ phone: "+1234567890" }); + expect(useQrStore.getState().smsData.phone).toBe("+1234567890"); + expect(useQrStore.getState().smsData.message).toBe(""); + }); + + it("setDotColor updates dot color", () => { + useQrStore.getState().setDotColor("#FF0000"); + expect(useQrStore.getState().dotColor).toBe("#FF0000"); + }); + + it("setBgTransparent toggles background transparency", () => { + useQrStore.getState().setBgTransparent(true); + expect(useQrStore.getState().bgTransparent).toBe(true); + }); + + it("setSize updates size", () => { + useQrStore.getState().setSize(500); + expect(useQrStore.getState().size).toBe(500); + }); + + it("setErrorCorrection updates error correction level", () => { + useQrStore.getState().setErrorCorrection("H"); + expect(useQrStore.getState().errorCorrection).toBe("H"); + }); + + it("setDownloadFormat updates download format", () => { + useQrStore.getState().setDownloadFormat("svg"); + expect(useQrStore.getState().downloadFormat).toBe("svg"); + }); + + it("setLogoFile with null clears logo data", () => { + useQrStore.getState().setLogoFile(null); + expect(useQrStore.getState().logoFile).toBeNull(); + expect(useQrStore.getState().logoDataUrl).toBeNull(); + }); + + it("reset restores all defaults", () => { + useQrStore.getState().setContentType("wifi"); + useQrStore.getState().setTextData("changed"); + useQrStore.getState().setDotColor("#FF0000"); + useQrStore.getState().setSize(500); + useQrStore.getState().setWifiData({ ssid: "test" }); + + useQrStore.getState().reset(); + const s = useQrStore.getState(); + expect(s.contentType).toBe("url"); + expect(s.textData).toBe(""); + expect(s.dotColor).toBe("#000000"); + expect(s.size).toBe(1000); + expect(s.wifiData.ssid).toBe(""); + }); +}); + +describe("encodeQrData", () => { + it("encodes url/text content type as plain text", () => { + const state = { + ...useQrStore.getState(), + contentType: "url" as const, + textData: "https://example.com", + }; + expect(encodeQrData(state)).toBe("https://example.com"); + }); + + it("encodes text content type as plain text", () => { + const state = { + ...useQrStore.getState(), + contentType: "text" as const, + textData: "Hello World", + }; + expect(encodeQrData(state)).toBe("Hello World"); + }); + + it("encodes wifi data in WIFI: format", () => { + const state = { + ...useQrStore.getState(), + contentType: "wifi" as const, + wifiData: { ssid: "MyNet", password: "pass123", encryption: "WPA" as const, hidden: false }, + }; + expect(encodeQrData(state)).toBe("WIFI:T:WPA;S:MyNet;P:pass123;H:false;;"); + }); + + it("encodes vcard data in vCard 3.0 format", () => { + const state = { + ...useQrStore.getState(), + contentType: "vcard" as const, + vcardData: { + firstName: "John", + lastName: "Doe", + phone: "+1234567890", + email: "john@example.com", + organization: "Acme", + title: "Engineer", + url: "https://johndoe.com", + }, + }; + const result = encodeQrData(state); + expect(result).toContain("BEGIN:VCARD"); + expect(result).toContain("VERSION:3.0"); + expect(result).toContain("N:Doe;John"); + expect(result).toContain("FN:John Doe"); + expect(result).toContain("TEL:+1234567890"); + expect(result).toContain("EMAIL:john@example.com"); + expect(result).toContain("ORG:Acme"); + expect(result).toContain("TITLE:Engineer"); + expect(result).toContain("URL:https://johndoe.com"); + expect(result).toContain("END:VCARD"); + }); + + it("encodes vcard without optional fields", () => { + const state = { + ...useQrStore.getState(), + contentType: "vcard" as const, + vcardData: { + firstName: "Jane", + lastName: "", + phone: "", + email: "", + organization: "", + title: "", + url: "", + }, + }; + const result = encodeQrData(state); + expect(result).toContain("FN:Jane"); + expect(result).not.toContain("TEL:"); + expect(result).not.toContain("EMAIL:"); + }); + + it("encodes email data as mailto URI", () => { + const state = { + ...useQrStore.getState(), + contentType: "email" as const, + emailData: { to: "test@example.com", subject: "Hello World", body: "Hi there" }, + }; + const result = encodeQrData(state); + expect(result).toContain("mailto:test@example.com"); + expect(result).toContain("subject=Hello%20World"); + expect(result).toContain("body=Hi%20there"); + }); + + it("encodes email without subject/body", () => { + const state = { + ...useQrStore.getState(), + contentType: "email" as const, + emailData: { to: "plain@test.com", subject: "", body: "" }, + }; + expect(encodeQrData(state)).toBe("mailto:plain@test.com"); + }); + + it("encodes phone data as tel URI", () => { + const state = { + ...useQrStore.getState(), + contentType: "phone" as const, + phoneData: "+1234567890", + }; + expect(encodeQrData(state)).toBe("tel:+1234567890"); + }); + + it("encodes sms data as smsto URI", () => { + const state = { + ...useQrStore.getState(), + contentType: "sms" as const, + smsData: { phone: "+1234567890", message: "Hey!" }, + }; + expect(encodeQrData(state)).toBe("smsto:+1234567890:Hey!"); + }); +}); + +// ========================================================================== +// SplitStore +// ========================================================================== + +import { useSplitStore } from "@/stores/split-store"; + +describe("useSplitStore", () => { + beforeEach(() => { + revokeObjectURL.mockClear(); + useSplitStore.setState({ + mode: "grid", + columns: 3, + rows: 3, + tileWidth: 200, + tileHeight: 200, + outputFormat: "original", + quality: 90, + imageDimensions: null, + processing: false, + error: null, + tiles: [], + zipBlobUrl: null, + }); + }); + + it("has correct initial state", () => { + const s = useSplitStore.getState(); + expect(s.mode).toBe("grid"); + expect(s.columns).toBe(3); + expect(s.rows).toBe(3); + expect(s.tileWidth).toBe(200); + expect(s.tileHeight).toBe(200); + expect(s.outputFormat).toBe("original"); + expect(s.quality).toBe(90); + expect(s.imageDimensions).toBeNull(); + expect(s.processing).toBe(false); + expect(s.error).toBeNull(); + expect(s.tiles).toEqual([]); + expect(s.zipBlobUrl).toBeNull(); + }); + + it("setMode changes mode and clears tiles/zip/error", () => { + useSplitStore.getState().setTiles([ + { + row: 0, + col: 0, + label: "1", + width: 100, + height: 100, + blobUrl: null, + }, + ]); + useSplitStore.getState().setMode("tile-size"); + const s = useSplitStore.getState(); + expect(s.mode).toBe("tile-size"); + expect(s.tiles).toEqual([]); + expect(s.error).toBeNull(); + }); + + it("setColumns clamps to 1-100 range", () => { + useSplitStore.getState().setColumns(0); + expect(useSplitStore.getState().columns).toBe(1); + + useSplitStore.getState().setColumns(150); + expect(useSplitStore.getState().columns).toBe(100); + + useSplitStore.getState().setColumns(5); + expect(useSplitStore.getState().columns).toBe(5); + }); + + it("setRows clamps to 1-100 range", () => { + useSplitStore.getState().setRows(-1); + expect(useSplitStore.getState().rows).toBe(1); + + useSplitStore.getState().setRows(200); + expect(useSplitStore.getState().rows).toBe(100); + }); + + it("setTileWidth clamps to minimum 10", () => { + useSplitStore.getState().setTileWidth(5); + expect(useSplitStore.getState().tileWidth).toBe(10); + + useSplitStore.getState().setTileWidth(300); + expect(useSplitStore.getState().tileWidth).toBe(300); + }); + + it("setTileHeight clamps to minimum 10", () => { + useSplitStore.getState().setTileHeight(1); + expect(useSplitStore.getState().tileHeight).toBe(10); + }); + + it("setZipBlobUrl revokes previous URL", () => { + useSplitStore.setState({ zipBlobUrl: "blob:old-zip" }); + revokeObjectURL.mockClear(); + + useSplitStore.getState().setZipBlobUrl("blob:new-zip"); + expect(revokeObjectURL).toHaveBeenCalledWith("blob:old-zip"); + expect(useSplitStore.getState().zipBlobUrl).toBe("blob:new-zip"); + }); + + it("applyPreset sets mode to grid with given cols/rows", () => { + useSplitStore.getState().applyPreset(4, 4); + const s = useSplitStore.getState(); + expect(s.mode).toBe("grid"); + expect(s.columns).toBe(4); + expect(s.rows).toBe(4); + expect(s.tiles).toEqual([]); + }); + + it("getEffectiveGrid returns columns/rows in grid mode", () => { + useSplitStore.getState().setColumns(5); + useSplitStore.getState().setRows(3); + const grid = useSplitStore.getState().getEffectiveGrid(); + expect(grid).toEqual({ columns: 5, rows: 3 }); + }); + + it("getEffectiveGrid computes from tile size in tile-size mode", () => { + useSplitStore.getState().setMode("tile-size"); + useSplitStore.getState().setTileWidth(100); + useSplitStore.getState().setTileHeight(50); + useSplitStore.getState().setImageDimensions({ width: 300, height: 200 }); + const grid = useSplitStore.getState().getEffectiveGrid(); + expect(grid).toEqual({ columns: 3, rows: 4 }); + }); + + it("getTileCount returns columns * rows", () => { + useSplitStore.getState().setColumns(4); + useSplitStore.getState().setRows(3); + expect(useSplitStore.getState().getTileCount()).toBe(12); + }); + + it("getComputedTileDimensions returns null without image dimensions", () => { + expect(useSplitStore.getState().getComputedTileDimensions()).toBeNull(); + }); + + it("getComputedTileDimensions computes tile size in grid mode", () => { + useSplitStore.getState().setImageDimensions({ width: 900, height: 600 }); + useSplitStore.getState().setColumns(3); + useSplitStore.getState().setRows(2); + const dims = useSplitStore.getState().getComputedTileDimensions(); + expect(dims).toEqual({ width: 300, height: 300 }); + }); + + it("getComputedTileDimensions returns tile size in tile-size mode", () => { + useSplitStore.getState().setMode("tile-size"); + useSplitStore.getState().setTileWidth(150); + useSplitStore.getState().setTileHeight(75); + useSplitStore.getState().setImageDimensions({ width: 600, height: 300 }); + const dims = useSplitStore.getState().getComputedTileDimensions(); + expect(dims).toEqual({ width: 150, height: 75 }); + }); + + it("reset revokes zip and tile blob URLs", () => { + useSplitStore.setState({ + zipBlobUrl: "blob:zip", + tiles: [ + { row: 0, col: 0, label: "1", width: 100, height: 100, blobUrl: "blob:tile1" }, + { row: 0, col: 1, label: "2", width: 100, height: 100, blobUrl: null }, + ], + }); + revokeObjectURL.mockClear(); + + useSplitStore.getState().reset(); + expect(revokeObjectURL).toHaveBeenCalledWith("blob:zip"); + expect(revokeObjectURL).toHaveBeenCalledWith("blob:tile1"); + expect(useSplitStore.getState().tiles).toEqual([]); + expect(useSplitStore.getState().zipBlobUrl).toBeNull(); + }); +}); + +// ========================================================================== +// ThemeStore +// ========================================================================== + +import { useThemeStore } from "@/stores/theme-store"; + +describe("useThemeStore", () => { + beforeEach(() => { + // Reset to initial values + useThemeStore.setState({ theme: "light", resolvedTheme: "light" }); + }); + + it("has correct initial state", () => { + const s = useThemeStore.getState(); + expect(s.theme).toBe("light"); + expect(s.resolvedTheme).toBe("light"); + }); + + it("setTheme to dark updates theme and resolvedTheme", () => { + useThemeStore.getState().setTheme("dark"); + const s = useThemeStore.getState(); + expect(s.theme).toBe("dark"); + expect(s.resolvedTheme).toBe("dark"); + }); + + it("setTheme to light updates theme and resolvedTheme", () => { + useThemeStore.getState().setTheme("dark"); + useThemeStore.getState().setTheme("light"); + const s = useThemeStore.getState(); + expect(s.theme).toBe("light"); + expect(s.resolvedTheme).toBe("light"); + }); + + it("setTheme to system resolves based on matchMedia", () => { + useThemeStore.getState().setTheme("system"); + const s = useThemeStore.getState(); + expect(s.theme).toBe("system"); + // matchMedia mock returns matches: false, so system resolves to "light" + expect(s.resolvedTheme).toBe("light"); + }); +}); + +// ========================================================================== +// PdfToImageStore (synchronous state operations only) +// ========================================================================== + +import { usePdfToImageStore } from "@/stores/pdf-to-image-store"; + +describe("usePdfToImageStore", () => { + beforeEach(() => { + usePdfToImageStore.getState().reset(); + }); + + it("has correct initial state", () => { + const s = usePdfToImageStore.getState(); + expect(s.file).toBeNull(); + expect(s.pageCount).toBeNull(); + expect(s.thumbnails).toEqual([]); + expect(s.format).toBe("png"); + expect(s.dpi).toBe(150); + expect(s.customDpi).toBe(false); + expect(s.quality).toBe(85); + expect(s.colorMode).toBe("color"); + expect(s.pages).toBe(""); + expect(s.selectedPages.size).toBe(0); + expect(s.processing).toBe(false); + expect(s.loadingPreview).toBe(false); + expect(s.error).toBeNull(); + expect(s.results).toBeNull(); + expect(s.zipUrl).toBeNull(); + expect(s.zipSize).toBeNull(); + }); + + it("setFormat updates format", () => { + usePdfToImageStore.getState().setFormat("jpg"); + expect(usePdfToImageStore.getState().format).toBe("jpg"); + }); + + it("setDpi updates dpi", () => { + usePdfToImageStore.getState().setDpi(300); + expect(usePdfToImageStore.getState().dpi).toBe(300); + }); + + it("setCustomDpi updates customDpi flag", () => { + usePdfToImageStore.getState().setCustomDpi(true); + expect(usePdfToImageStore.getState().customDpi).toBe(true); + }); + + it("setQuality updates quality", () => { + usePdfToImageStore.getState().setQuality(50); + expect(usePdfToImageStore.getState().quality).toBe(50); + }); + + it("setColorMode updates color mode", () => { + usePdfToImageStore.getState().setColorMode("grayscale"); + expect(usePdfToImageStore.getState().colorMode).toBe("grayscale"); + }); + + it("setFile with null resets to initial state", () => { + usePdfToImageStore.getState().setFormat("jpg"); + usePdfToImageStore.getState().setDpi(300); + usePdfToImageStore.getState().setFile(null); + const s = usePdfToImageStore.getState(); + expect(s.file).toBeNull(); + // format and dpi are part of initialState spread, so they also reset + expect(s.format).toBe("png"); + expect(s.dpi).toBe(150); + }); + + it("setFile with a File resets results and pages", () => { + const file = new File(["pdf content"], "test.pdf", { type: "application/pdf" }); + usePdfToImageStore.getState().setFile(file); + const s = usePdfToImageStore.getState(); + expect(s.file).toBe(file); + expect(s.pageCount).toBeNull(); + expect(s.thumbnails).toEqual([]); + expect(s.results).toBeNull(); + expect(s.error).toBeNull(); + expect(s.selectedPages.size).toBe(0); + expect(s.pages).toBe(""); + }); + + it("togglePage adds and removes pages", () => { + usePdfToImageStore.setState({ pageCount: 5 }); + usePdfToImageStore.getState().togglePage(3); + expect(usePdfToImageStore.getState().selectedPages.has(3)).toBe(true); + + usePdfToImageStore.getState().togglePage(3); + expect(usePdfToImageStore.getState().selectedPages.has(3)).toBe(false); + }); + + it("selectAllPages selects all pages when pageCount is set", () => { + usePdfToImageStore.setState({ pageCount: 4 }); + usePdfToImageStore.getState().selectAllPages(); + const pages = usePdfToImageStore.getState().selectedPages; + expect(pages.size).toBe(4); + expect(pages.has(1)).toBe(true); + expect(pages.has(4)).toBe(true); + expect(usePdfToImageStore.getState().pages).toBe(""); + }); + + it("selectAllPages is a no-op when pageCount is null", () => { + usePdfToImageStore.getState().selectAllPages(); + expect(usePdfToImageStore.getState().selectedPages.size).toBe(0); + }); + + it("deselectAllPages clears selection and sets pages to 'none'", () => { + usePdfToImageStore.setState({ pageCount: 3 }); + usePdfToImageStore.getState().selectAllPages(); + usePdfToImageStore.getState().deselectAllPages(); + expect(usePdfToImageStore.getState().selectedPages.size).toBe(0); + expect(usePdfToImageStore.getState().pages).toBe("none"); + }); + + it("reset restores initial state", () => { + usePdfToImageStore.getState().setFormat("webp"); + usePdfToImageStore.getState().setDpi(600); + usePdfToImageStore.setState({ processing: true, error: "something" }); + + usePdfToImageStore.getState().reset(); + const s = usePdfToImageStore.getState(); + expect(s.format).toBe("png"); + expect(s.dpi).toBe(150); + expect(s.processing).toBe(false); + expect(s.error).toBeNull(); + }); +}); + +// ========================================================================== +// CollageStore +// ========================================================================== + +// Mock the collage-templates module so we don't depend on real template data. +// The store uses getDefaultTemplate() and COLLAGE_TEMPLATES — provide +// minimal stubs that return predictable values. +vi.mock("@/lib/collage-templates", () => ({ + COLLAGE_TEMPLATES: [ + { + id: "2-h-equal", + imageCount: 2, + label: "Side by side", + gridTemplateColumns: "1fr 1fr", + gridTemplateRows: "1fr", + cells: [ + { gridColumn: "1", gridRow: "1" }, + { gridColumn: "2", gridRow: "1" }, + ], + }, + { + id: "3-h-equal", + imageCount: 3, + label: "Three across", + gridTemplateColumns: "1fr 1fr 1fr", + gridTemplateRows: "1fr", + cells: [ + { gridColumn: "1", gridRow: "1" }, + { gridColumn: "2", gridRow: "1" }, + { gridColumn: "3", gridRow: "1" }, + ], + }, + ], + getDefaultTemplate: (count: number) => { + if (count >= 3) { + return { + id: "3-h-equal", + imageCount: 3, + cells: [ + { gridColumn: "1", gridRow: "1" }, + { gridColumn: "2", gridRow: "1" }, + { gridColumn: "3", gridRow: "1" }, + ], + }; + } + return { + id: "2-h-equal", + imageCount: 2, + cells: [ + { gridColumn: "1", gridRow: "1" }, + { gridColumn: "2", gridRow: "1" }, + ], + }; + }, +})); + +// Mock image-preview helpers to avoid real fetch calls +vi.mock("@/lib/image-preview", () => ({ + needsServerPreview: vi.fn(() => false), + fetchDecodedPreview: vi.fn(() => Promise.resolve(null)), + revokePreviewUrl: vi.fn(), +})); + +import { useCollageStore } from "@/stores/collage-store"; + +describe("useCollageStore", () => { + beforeEach(() => { + revokeObjectURL.mockClear(); + createObjectURL.mockClear(); + useCollageStore.getState().reset(); + }); + + it("has correct initial state", () => { + const s = useCollageStore.getState(); + expect(s.images).toEqual([]); + expect(s.templateId).toBe("2-h-equal"); + expect(s.cellAssignments).toEqual([]); + expect(s.cellTransforms).toEqual({}); + expect(s.gap).toBe(8); + expect(s.cornerRadius).toBe(0); + expect(s.backgroundColor).toBe("#FFFFFF"); + expect(s.bgPreset).toBe("white"); + expect(s.aspectRatio).toBe("free"); + expect(s.outputFormat).toBe("png"); + expect(s.quality).toBe(90); + expect(s.selectedCell).toBeNull(); + expect(s.phase).toBe("upload"); + expect(s.progress).toBe(0); + expect(s.resultUrl).toBeNull(); + expect(s.resultSize).toBeNull(); + expect(s.originalSize).toBeNull(); + expect(s.error).toBeNull(); + expect(s.jobId).toBeNull(); + }); + + it("addImages creates CollageImage entries and transitions to editing phase", () => { + const file1 = new File(["a"], "photo1.png", { type: "image/png" }); + const file2 = new File(["b"], "photo2.png", { type: "image/png" }); + useCollageStore.getState().addImages([file1, file2]); + + const s = useCollageStore.getState(); + expect(s.images).toHaveLength(2); + expect(s.images[0].file).toBe(file1); + expect(s.images[0].blobUrl).toBe("blob:fake-url"); + expect(s.images[1].file).toBe(file2); + expect(s.phase).toBe("editing"); + expect(s.templateId).toBe("2-h-equal"); + expect(s.cellAssignments).toHaveLength(2); + expect(s.cellAssignments[0]).toBe(0); + expect(s.cellAssignments[1]).toBe(1); + }); + + it("addImages appends to existing images", () => { + const file1 = new File(["a"], "a.png", { type: "image/png" }); + const file2 = new File(["b"], "b.png", { type: "image/png" }); + const file3 = new File(["c"], "c.png", { type: "image/png" }); + useCollageStore.getState().addImages([file1, file2]); + useCollageStore.getState().addImages([file3]); + + const s = useCollageStore.getState(); + expect(s.images).toHaveLength(3); + expect(s.templateId).toBe("3-h-equal"); + }); + + it("removeImage removes image by index and revokes blob URL", () => { + const file1 = new File(["a"], "a.png", { type: "image/png" }); + const file2 = new File(["b"], "b.png", { type: "image/png" }); + useCollageStore.getState().addImages([file1, file2]); + revokeObjectURL.mockClear(); + + useCollageStore.getState().removeImage(0); + + const s = useCollageStore.getState(); + expect(s.images).toHaveLength(1); + expect(s.images[0].file).toBe(file2); + expect(revokeObjectURL).toHaveBeenCalledWith("blob:fake-url"); + }); + + it("removeImage resets when last image is removed", () => { + const file = new File(["a"], "a.png", { type: "image/png" }); + useCollageStore.getState().addImages([file]); + + useCollageStore.getState().removeImage(0); + const s = useCollageStore.getState(); + expect(s.images).toEqual([]); + expect(s.phase).toBe("upload"); + }); + + it("clearImages revokes all blob URLs and resets to upload phase", () => { + const file1 = new File(["a"], "a.png", { type: "image/png" }); + const file2 = new File(["b"], "b.png", { type: "image/png" }); + useCollageStore.getState().addImages([file1, file2]); + revokeObjectURL.mockClear(); + + useCollageStore.getState().clearImages(); + + const s = useCollageStore.getState(); + expect(s.images).toEqual([]); + expect(s.phase).toBe("upload"); + expect(s.cellAssignments).toEqual([]); + expect(revokeObjectURL).toHaveBeenCalledTimes(2); + }); + + it("setTemplateId changes template and rebuilds assignments", () => { + const file1 = new File(["a"], "a.png", { type: "image/png" }); + useCollageStore.getState().addImages([file1]); + useCollageStore.getState().setTemplateId("3-h-equal"); + + const s = useCollageStore.getState(); + expect(s.templateId).toBe("3-h-equal"); + expect(s.cellAssignments).toHaveLength(3); + expect(s.cellAssignments[0]).toBe(0); + expect(s.cellAssignments[1]).toBe(-1); + expect(s.cellAssignments[2]).toBe(-1); + expect(s.cellTransforms).toEqual({}); + }); + + it("setTemplateId ignores unknown template IDs", () => { + useCollageStore.getState().setTemplateId("nonexistent"); + expect(useCollageStore.getState().templateId).toBe("2-h-equal"); + }); + + it("setCellAssignment updates a single cell", () => { + const file1 = new File(["a"], "a.png", { type: "image/png" }); + const file2 = new File(["b"], "b.png", { type: "image/png" }); + useCollageStore.getState().addImages([file1, file2]); + + useCollageStore.getState().setCellAssignment(0, 1); + expect(useCollageStore.getState().cellAssignments[0]).toBe(1); + expect(useCollageStore.getState().resultUrl).toBeNull(); + }); + + it("swapCells swaps assignments and transforms between two cells", () => { + const file1 = new File(["a"], "a.png", { type: "image/png" }); + const file2 = new File(["b"], "b.png", { type: "image/png" }); + useCollageStore.getState().addImages([file1, file2]); + + useCollageStore.getState().setCellTransform(0, { zoom: 2 }); + + useCollageStore.getState().swapCells(0, 1); + + const s = useCollageStore.getState(); + expect(s.cellAssignments[0]).toBe(1); + expect(s.cellAssignments[1]).toBe(0); + expect(s.cellTransforms[1]?.zoom).toBe(2); + }); + + it("setCellTransform creates and merges transforms", () => { + useCollageStore.getState().setCellTransform(0, { panX: 50 }); + expect(useCollageStore.getState().cellTransforms[0].panX).toBe(50); + expect(useCollageStore.getState().cellTransforms[0].zoom).toBe(1); + + useCollageStore.getState().setCellTransform(0, { zoom: 2.5 }); + expect(useCollageStore.getState().cellTransforms[0].panX).toBe(50); + expect(useCollageStore.getState().cellTransforms[0].zoom).toBe(2.5); + }); + + it("resetCellTransform removes transform for a cell", () => { + useCollageStore.getState().setCellTransform(0, { zoom: 2 }); + expect(useCollageStore.getState().cellTransforms[0]).toBeDefined(); + + useCollageStore.getState().resetCellTransform(0); + expect(useCollageStore.getState().cellTransforms[0]).toBeUndefined(); + }); + + it("setGap updates gap and clears resultUrl", () => { + useCollageStore.setState({ resultUrl: "blob:old" }); + useCollageStore.getState().setGap(16); + expect(useCollageStore.getState().gap).toBe(16); + expect(useCollageStore.getState().resultUrl).toBeNull(); + }); + + it("setCornerRadius updates corner radius", () => { + useCollageStore.getState().setCornerRadius(12); + expect(useCollageStore.getState().cornerRadius).toBe(12); + }); + + it("setBackgroundColor updates backgroundColor", () => { + useCollageStore.getState().setBackgroundColor("#FF0000"); + expect(useCollageStore.getState().backgroundColor).toBe("#FF0000"); + }); + + it("setBgPreset updates preset and color for named presets", () => { + useCollageStore.getState().setBgPreset("black"); + expect(useCollageStore.getState().bgPreset).toBe("black"); + expect(useCollageStore.getState().backgroundColor).toBe("#000000"); + + useCollageStore.getState().setBgPreset("transparent"); + expect(useCollageStore.getState().backgroundColor).toBe("transparent"); + }); + + it("setBgPreset with custom does not overwrite backgroundColor", () => { + useCollageStore.getState().setBackgroundColor("#123456"); + useCollageStore.getState().setBgPreset("custom"); + expect(useCollageStore.getState().bgPreset).toBe("custom"); + expect(useCollageStore.getState().backgroundColor).toBe("#123456"); + }); + + it("setAspectRatio updates aspect ratio", () => { + useCollageStore.getState().setAspectRatio("16:9"); + expect(useCollageStore.getState().aspectRatio).toBe("16:9"); + }); + + it("setOutputFormat updates output format", () => { + useCollageStore.getState().setOutputFormat("webp"); + expect(useCollageStore.getState().outputFormat).toBe("webp"); + }); + + it("setQuality updates quality", () => { + useCollageStore.getState().setQuality(75); + expect(useCollageStore.getState().quality).toBe(75); + }); + + it("setSelectedCell updates selected cell", () => { + useCollageStore.getState().setSelectedCell(1); + expect(useCollageStore.getState().selectedCell).toBe(1); + useCollageStore.getState().setSelectedCell(null); + expect(useCollageStore.getState().selectedCell).toBeNull(); + }); + + it("setPhase updates phase", () => { + useCollageStore.getState().setPhase("processing"); + expect(useCollageStore.getState().phase).toBe("processing"); + }); + + it("setProgress updates progress", () => { + useCollageStore.getState().setProgress(42); + expect(useCollageStore.getState().progress).toBe(42); + }); + + it("setResult sets result fields and transitions to result phase", () => { + useCollageStore.getState().setResult("blob:result", 5000, 10000, "job-123"); + const s = useCollageStore.getState(); + expect(s.resultUrl).toBe("blob:result"); + expect(s.resultSize).toBe(5000); + expect(s.originalSize).toBe(10000); + expect(s.jobId).toBe("job-123"); + expect(s.phase).toBe("result"); + expect(s.error).toBeNull(); + }); + + it("setError sets error and returns to editing phase", () => { + useCollageStore.getState().setPhase("processing"); + useCollageStore.getState().setError("Something went wrong"); + const s = useCollageStore.getState(); + expect(s.error).toBe("Something went wrong"); + expect(s.phase).toBe("editing"); + }); + + it("reset revokes all blob URLs and restores initial state", () => { + const file1 = new File(["a"], "a.png", { type: "image/png" }); + useCollageStore.getState().addImages([file1]); + useCollageStore.getState().setGap(20); + useCollageStore.getState().setCornerRadius(10); + useCollageStore.getState().setOutputFormat("jpeg"); + revokeObjectURL.mockClear(); + + useCollageStore.getState().reset(); + + expect(revokeObjectURL).toHaveBeenCalled(); + const s = useCollageStore.getState(); + expect(s.images).toEqual([]); + expect(s.gap).toBe(8); + expect(s.cornerRadius).toBe(0); + expect(s.outputFormat).toBe("png"); + expect(s.phase).toBe("upload"); + }); +}); + +// ========================================================================== +// SettingsStore +// ========================================================================== + +// Mock the api module to prevent real network calls +vi.mock("@/lib/api", () => ({ + apiGet: vi.fn(), + apiPost: vi.fn(), + apiPut: vi.fn(), + apiDelete: vi.fn(), + formatHeaders: vi.fn(() => new Headers()), +})); + +import { apiGet } from "@/lib/api"; +import { useSettingsStore } from "@/stores/settings-store"; + +const mockApiGet = vi.mocked(apiGet); + +describe("useSettingsStore", () => { + beforeEach(() => { + useSettingsStore.setState({ + disabledTools: [], + experimentalEnabled: false, + defaultToolView: "sidebar", + loaded: false, + }); + mockApiGet.mockReset(); + }); + + it("has correct initial state", () => { + const s = useSettingsStore.getState(); + expect(s.disabledTools).toEqual([]); + expect(s.experimentalEnabled).toBe(false); + expect(s.defaultToolView).toBe("sidebar"); + expect(s.loaded).toBe(false); + }); + + it("fetch loads settings from API", async () => { + mockApiGet.mockResolvedValueOnce({ + settings: { + disabledTools: JSON.stringify(["resize", "crop"]), + enableExperimentalTools: "true", + defaultToolView: "fullscreen", + }, + }); + + await useSettingsStore.getState().fetch(); + + const s = useSettingsStore.getState(); + expect(s.disabledTools).toEqual(["resize", "crop"]); + expect(s.experimentalEnabled).toBe(true); + expect(s.defaultToolView).toBe("fullscreen"); + expect(s.loaded).toBe(true); + }); + + it("fetch skips when already loaded", async () => { + useSettingsStore.setState({ loaded: true }); + await useSettingsStore.getState().fetch(); + expect(mockApiGet).not.toHaveBeenCalled(); + }); + + it("fetch handles empty disabledTools gracefully", async () => { + mockApiGet.mockResolvedValueOnce({ + settings: {}, + }); + + await useSettingsStore.getState().fetch(); + + const s = useSettingsStore.getState(); + expect(s.disabledTools).toEqual([]); + expect(s.experimentalEnabled).toBe(false); + expect(s.defaultToolView).toBe("sidebar"); + expect(s.loaded).toBe(true); + }); + + it("fetch defaults defaultToolView to sidebar for unknown values", async () => { + mockApiGet.mockResolvedValueOnce({ + settings: { + defaultToolView: "unknown-value", + }, + }); + + await useSettingsStore.getState().fetch(); + expect(useSettingsStore.getState().defaultToolView).toBe("sidebar"); + }); + + it("fetch sets loaded=true on error", async () => { + mockApiGet.mockRejectedValueOnce(new Error("Network error")); + + await useSettingsStore.getState().fetch(); + + expect(useSettingsStore.getState().loaded).toBe(true); + expect(useSettingsStore.getState().disabledTools).toEqual([]); + }); +}); + +// ========================================================================== +// FeaturesStore +// ========================================================================== + +vi.mock("@ashim/shared", async (importOriginal) => { + const actual: Record = await importOriginal(); + return { + ...actual, + TOOL_BUNDLE_MAP: { + "remove-bg": "ai-rembg", + upscale: "ai-esrgan", + }, + }; +}); + +import { apiPost } from "@/lib/api"; +import { useFeaturesStore } from "@/stores/features-store"; + +const mockApiPost = vi.mocked(apiPost); + +describe("useFeaturesStore", () => { + beforeEach(() => { + mockApiGet.mockReset(); + mockApiPost.mockReset(); + fetchMock.mockReset(); + useFeaturesStore.setState({ + bundles: [], + loaded: false, + installing: {}, + errors: {}, + queued: [], + installAllActive: false, + startTimes: {}, + }); + }); + + it("has correct initial state", () => { + const s = useFeaturesStore.getState(); + expect(s.bundles).toEqual([]); + expect(s.loaded).toBe(false); + expect(s.installing).toEqual({}); + expect(s.errors).toEqual({}); + expect(s.queued).toEqual([]); + expect(s.installAllActive).toBe(false); + expect(s.startTimes).toEqual({}); + }); + + it("fetch loads bundles from API", async () => { + const bundles = [ + { + id: "ai-rembg", + name: "AI Background Remover", + description: "Remove backgrounds", + status: "installed" as const, + installedVersion: "1.0.0", + estimatedSize: "500MB", + enablesTools: ["remove-bg"], + progress: null, + error: null, + }, + ]; + mockApiGet.mockResolvedValueOnce({ bundles }); + + await useFeaturesStore.getState().fetch(); + + const s = useFeaturesStore.getState(); + expect(s.bundles).toEqual(bundles); + expect(s.loaded).toBe(true); + }); + + it("fetch skips when already loaded", async () => { + useFeaturesStore.setState({ loaded: true }); + await useFeaturesStore.getState().fetch(); + expect(mockApiGet).not.toHaveBeenCalled(); + }); + + it("fetch sets loaded=true on error", async () => { + mockApiGet.mockRejectedValueOnce(new Error("Network error")); + await useFeaturesStore.getState().fetch(); + expect(useFeaturesStore.getState().loaded).toBe(true); + expect(useFeaturesStore.getState().bundles).toEqual([]); + }); + + it("isToolInstalled returns true for tools whose bundle is installed", () => { + useFeaturesStore.setState({ + bundles: [ + { + id: "ai-rembg", + name: "AI Background Remover", + description: "Remove backgrounds", + status: "installed", + installedVersion: "1.0.0", + estimatedSize: "500MB", + enablesTools: ["remove-bg"], + progress: null, + error: null, + }, + ], + }); + expect(useFeaturesStore.getState().isToolInstalled("remove-bg")).toBe(true); + }); + + it("isToolInstalled returns false for tools whose bundle is not installed", () => { + useFeaturesStore.setState({ + bundles: [ + { + id: "ai-rembg", + name: "AI Background Remover", + description: "Remove backgrounds", + status: "not_installed", + installedVersion: null, + estimatedSize: "500MB", + enablesTools: ["remove-bg"], + progress: null, + error: null, + }, + ], + }); + expect(useFeaturesStore.getState().isToolInstalled("remove-bg")).toBe(false); + }); + + it("isToolInstalled returns true for tools without a bundle mapping", () => { + expect(useFeaturesStore.getState().isToolInstalled("resize")).toBe(true); + }); + + it("getBundleForTool returns the bundle for a mapped tool", () => { + const bundle = { + id: "ai-rembg", + name: "AI Background Remover", + description: "Remove backgrounds", + status: "installed" as const, + installedVersion: "1.0.0", + estimatedSize: "500MB", + enablesTools: ["remove-bg"], + progress: null, + error: null, + }; + useFeaturesStore.setState({ bundles: [bundle] }); + expect(useFeaturesStore.getState().getBundleForTool("remove-bg")).toEqual(bundle); + }); + + it("getBundleForTool returns null for unmapped tools", () => { + expect(useFeaturesStore.getState().getBundleForTool("resize")).toBeNull(); + }); + + it("clearError removes a specific bundle error", () => { + useFeaturesStore.setState({ + errors: { "ai-rembg": "Failed", "ai-esrgan": "Also failed" }, + }); + useFeaturesStore.getState().clearError("ai-rembg"); + expect(useFeaturesStore.getState().errors).toEqual({ "ai-esrgan": "Also failed" }); + }); + + it("uninstallBundle calls API and refreshes bundles", async () => { + mockApiPost.mockResolvedValueOnce({}); + mockApiGet.mockResolvedValueOnce({ bundles: [] }); + + await useFeaturesStore.getState().uninstallBundle("ai-rembg"); + + expect(mockApiPost).toHaveBeenCalledWith("/v1/admin/features/ai-rembg/uninstall"); + }); + + it("uninstallBundle sets error on failure", async () => { + mockApiPost.mockRejectedValueOnce(new Error("Uninstall failed")); + + await useFeaturesStore.getState().uninstallBundle("ai-rembg"); + + expect(useFeaturesStore.getState().errors["ai-rembg"]).toBe("Uninstall failed"); + }); + + it("installBundle sets initial progress and calls API", async () => { + // Mock EventSource globally + const mockClose = vi.fn(); + vi.stubGlobal( + "EventSource", + vi.fn().mockReturnValue({ + onmessage: null, + onerror: null, + close: mockClose, + }), + ); + + mockApiPost.mockResolvedValueOnce({ jobId: "job-123" }); + + await useFeaturesStore.getState().installBundle("ai-rembg"); + + expect(useFeaturesStore.getState().installing["ai-rembg"]).toBeDefined(); + expect(useFeaturesStore.getState().installing["ai-rembg"].percent).toBeGreaterThanOrEqual(5); + expect(useFeaturesStore.getState().startTimes["ai-rembg"]).toBeDefined(); + }); + + it("installBundle sets error on API failure", async () => { + mockApiPost.mockRejectedValueOnce(new Error("Server error")); + + await useFeaturesStore.getState().installBundle("ai-rembg"); + + expect(useFeaturesStore.getState().errors["ai-rembg"]).toBe("Server error"); + expect(useFeaturesStore.getState().installing["ai-rembg"]).toBeUndefined(); + }); +}); + +// ========================================================================== +// AnalyticsStore +// ========================================================================== + +// Mock the analytics lib to avoid importing Sentry/PostHog +vi.mock("@/lib/analytics", () => ({ + setAnalyticsConsent: vi.fn(), + initAnalytics: vi.fn(), + identify: vi.fn(), + trackEvent: vi.fn(), + trackPageView: vi.fn(), +})); + +import { setAnalyticsConsent } from "@/lib/analytics"; +import { apiPut } from "@/lib/api"; +import { useAnalyticsStore } from "@/stores/analytics-store"; + +const mockApiPut = vi.mocked(apiPut); +const mockSetAnalyticsConsent = vi.mocked(setAnalyticsConsent); + +describe("useAnalyticsStore", () => { + beforeEach(() => { + fetchMock.mockReset(); + mockApiPut.mockReset(); + mockSetAnalyticsConsent.mockClear(); + storageMap.clear(); + useAnalyticsStore.setState({ + config: null, + consent: { + analyticsEnabled: null, + analyticsConsentShownAt: null, + analyticsConsentRemindAt: null, + }, + configLoaded: false, + }); + }); + + it("has correct initial state", () => { + const s = useAnalyticsStore.getState(); + expect(s.config).toBeNull(); + expect(s.consent.analyticsEnabled).toBeNull(); + expect(s.consent.analyticsConsentShownAt).toBeNull(); + expect(s.consent.analyticsConsentRemindAt).toBeNull(); + expect(s.configLoaded).toBe(false); + }); + + it("fetchConfig loads config from API", async () => { + const config = { + enabled: true, + posthogApiKey: "phc_test", + posthogHost: "https://ph.example.com", + sentryDsn: "https://sentry.example.com", + sampleRate: 0.5, + instanceId: "inst-123", + }; + fetchMock.mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve(config), + }); + + await useAnalyticsStore.getState().fetchConfig(); + + const s = useAnalyticsStore.getState(); + expect(s.config).toEqual(config); + expect(s.configLoaded).toBe(true); + }); + + it("fetchConfig skips when already loaded", async () => { + useAnalyticsStore.setState({ configLoaded: true }); + await useAnalyticsStore.getState().fetchConfig(); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it("fetchConfig sets configLoaded=true on error", async () => { + fetchMock.mockRejectedValueOnce(new Error("Network error")); + await useAnalyticsStore.getState().fetchConfig(); + expect(useAnalyticsStore.getState().configLoaded).toBe(true); + expect(useAnalyticsStore.getState().config).toBeNull(); + }); + + it("setConsent updates consent and calls setAnalyticsConsent", () => { + const consent = { + analyticsEnabled: true, + analyticsConsentShownAt: 1000, + analyticsConsentRemindAt: null, + }; + useAnalyticsStore.getState().setConsent(consent); + + expect(useAnalyticsStore.getState().consent).toEqual(consent); + expect(mockSetAnalyticsConsent).toHaveBeenCalledWith(true); + }); + + it("acceptAnalytics calls API and sets consent to true", async () => { + mockApiPut.mockResolvedValueOnce({}); + + await useAnalyticsStore.getState().acceptAnalytics(); + + const s = useAnalyticsStore.getState(); + expect(s.consent.analyticsEnabled).toBe(true); + expect(s.consent.analyticsConsentShownAt).toBeTypeOf("number"); + expect(s.consent.analyticsConsentRemindAt).toBeNull(); + expect(mockSetAnalyticsConsent).toHaveBeenCalledWith(true); + }); + + it("acceptAnalytics falls back to localStorage on API error", async () => { + mockApiPut.mockRejectedValueOnce(new Error("Server error")); + + await useAnalyticsStore.getState().acceptAnalytics(); + + expect(localStorage.setItem).toHaveBeenCalledWith("ashim-analytics-consent", "true"); + expect(useAnalyticsStore.getState().consent.analyticsEnabled).toBe(true); + }); + + it("declineAnalytics calls API and sets consent to false", async () => { + mockApiPut.mockResolvedValueOnce({}); + + await useAnalyticsStore.getState().declineAnalytics(); + + const s = useAnalyticsStore.getState(); + expect(s.consent.analyticsEnabled).toBe(false); + expect(s.consent.analyticsConsentShownAt).toBeTypeOf("number"); + expect(mockSetAnalyticsConsent).toHaveBeenCalledWith(false); + }); + + it("declineAnalytics falls back to localStorage on API error", async () => { + mockApiPut.mockRejectedValueOnce(new Error("Server error")); + + await useAnalyticsStore.getState().declineAnalytics(); + + expect(localStorage.setItem).toHaveBeenCalledWith("ashim-analytics-consent", "false"); + }); + + it("remindLater calls API and sets remind-at 7 days in the future", async () => { + mockApiPut.mockResolvedValueOnce({}); + + const before = Date.now(); + await useAnalyticsStore.getState().remindLater(); + const after = Date.now(); + + const s = useAnalyticsStore.getState(); + expect(s.consent.analyticsEnabled).toBeNull(); + expect(s.consent.analyticsConsentRemindAt).toBeTypeOf("number"); + // Remind-at should be approximately 7 days from now + const sevenDays = 7 * 24 * 60 * 60 * 1000; + expect(s.consent.analyticsConsentRemindAt!).toBeGreaterThanOrEqual(before + sevenDays); + expect(s.consent.analyticsConsentRemindAt!).toBeLessThanOrEqual(after + sevenDays); + expect(mockSetAnalyticsConsent).toHaveBeenCalledWith(false); + }); + + it("remindLater falls back to localStorage on API error", async () => { + mockApiPut.mockRejectedValueOnce(new Error("Server error")); + + await useAnalyticsStore.getState().remindLater(); + + expect(localStorage.setItem).toHaveBeenCalledWith("ashim-analytics-consent", "remind"); + }); + + it("toggleAnalytics enables analytics", async () => { + mockApiPut.mockResolvedValueOnce({}); + + await useAnalyticsStore.getState().toggleAnalytics(true); + + expect(useAnalyticsStore.getState().consent.analyticsEnabled).toBe(true); + expect(mockSetAnalyticsConsent).toHaveBeenCalledWith(true); + }); + + it("toggleAnalytics disables analytics", async () => { + mockApiPut.mockResolvedValueOnce({}); + + await useAnalyticsStore.getState().toggleAnalytics(false); + + expect(useAnalyticsStore.getState().consent.analyticsEnabled).toBe(false); + expect(mockSetAnalyticsConsent).toHaveBeenCalledWith(false); + }); + + it("toggleAnalytics falls back to localStorage on API error", async () => { + mockApiPut.mockRejectedValueOnce(new Error("Server error")); + + await useAnalyticsStore.getState().toggleAnalytics(true); + + expect(localStorage.setItem).toHaveBeenCalledWith("ashim-analytics-consent", "true"); + expect(useAnalyticsStore.getState().consent.analyticsEnabled).toBe(true); + }); +}); diff --git a/vitest.config.ts b/vitest.config.ts index bd98462b..1b2998f2 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -97,6 +97,7 @@ export default defineConfig({ // from the root vitest runner. react: path.join(webNodeModules, "react"), "react-dom": path.join(webNodeModules, "react-dom"), + zustand: path.join(webNodeModules, "zustand"), }, }, });