diff --git a/README.md b/README.md index d8f64a8..5d74d8e 100644 --- a/README.md +++ b/README.md @@ -26,6 +26,7 @@ https://github.com/user-attachments/assets/8b1b8ef2-9fba-4951-bc9a-beaed4f6aff6 - [GeoJSON Preview tool (Beta)](#geojson-preview-tool-beta) - [Coordinate Conversion tool](#coordinate-conversion-tool) - [Bounding Box tool](#bounding-box-tool) + - [Style Optimization tool](#style-optimization-tool) - [Resources](#resources) - [Observability \& Tracing](#observability--tracing) - [Features](#features) @@ -453,6 +454,70 @@ An array of four numbers representing the bounding box: `[minX, minY, maxX, maxY - "Calculate the bounding box of this GeoJSON file" (then upload a .geojson file) - "What's the bounding box for the coordinates in the uploaded parks.geojson file?" +#### Style Optimization tool + +Optimizes Mapbox styles by removing redundancies, simplifying expressions, and reducing file size. + +**Parameters:** + +- `style` (string or object, required): Mapbox style to optimize (JSON string or style object) +- `optimizations` (array, optional): Specific optimizations to apply. If not specified, all optimizations are applied. Available optimizations: + - `remove-unused-sources`: Remove sources not referenced by any layer + - `remove-duplicate-layers`: Remove layers that are exact duplicates + - `simplify-expressions`: Simplify boolean expressions (e.g., `["all", true]` → `true`) + - `remove-empty-layers`: Remove layers with no visible properties (excluding background layers) + - `consolidate-filters`: Identify layers with identical filters that could be consolidated + +**Optimizations performed:** + +- **Remove unused sources**: Identifies and removes source definitions that aren't referenced by any layer +- **Remove duplicate layers**: Detects layers with identical properties (excluding ID) and removes duplicates +- **Simplify expressions**: Simplifies boolean logic in filters and property expressions: + - `["all", true]` → `true` + - `["any", false]` → `false` + - `["!", false]` → `true` + - `["!", true]` → `false` +- **Remove empty layers**: Removes layers with no paint or layout properties (background layers are preserved) +- **Consolidate filters**: Identifies groups of layers with identical filter expressions + +**Returns:** + +A JSON object with: + +- `optimizedStyle`: The optimized Mapbox style +- `optimizations`: Array of optimizations applied +- `summary`: Statistics including size savings and percent reduction + +**Example:** + +```json +{ + "optimizedStyle": { "version": 8, "sources": {}, "layers": [] }, + "optimizations": [ + { + "type": "remove-unused-sources", + "description": "Removed 2 unused source(s): unused-source1, unused-source2", + "count": 2 + } + ], + "summary": { + "totalOptimizations": 2, + "originalSize": 1234, + "optimizedSize": 890, + "sizeSaved": 344, + "percentReduction": 27.88 + } +} +``` + +**Example prompts:** + +- "Optimize this Mapbox style to reduce its file size" +- "Remove unused sources from my style" +- "Simplify the expressions in this style" +- "Find and remove duplicate layers in my map style" +- "Optimize my style but only remove unused sources and empty layers" + ## Agent Skills This repository includes [Agent Skills](https://agentskills.io) that provide domain expertise for building maps with Mapbox. Skills teach AI assistants about map design, security best practices, and common implementation patterns. diff --git a/src/tools/optimize-style-tool/OptimizeStyleTool.input.schema.ts b/src/tools/optimize-style-tool/OptimizeStyleTool.input.schema.ts new file mode 100644 index 0000000..e9271ed --- /dev/null +++ b/src/tools/optimize-style-tool/OptimizeStyleTool.input.schema.ts @@ -0,0 +1,26 @@ +// Copyright (c) Mapbox, Inc. +// Licensed under the MIT License. + +import { z } from 'zod'; + +export const OptimizeStyleInputSchema = z.object({ + style: z + .union([z.string(), z.record(z.unknown())]) + .describe('Mapbox style to optimize (JSON string or style object)'), + optimizations: z + .array( + z.enum([ + 'remove-unused-sources', + 'remove-duplicate-layers', + 'simplify-expressions', + 'remove-empty-layers', + 'consolidate-filters' + ]) + ) + .optional() + .describe( + 'Specific optimizations to apply (if not specified, all optimizations are applied)' + ) +}); + +export type OptimizeStyleInput = z.infer; diff --git a/src/tools/optimize-style-tool/OptimizeStyleTool.output.schema.ts b/src/tools/optimize-style-tool/OptimizeStyleTool.output.schema.ts new file mode 100644 index 0000000..7142f29 --- /dev/null +++ b/src/tools/optimize-style-tool/OptimizeStyleTool.output.schema.ts @@ -0,0 +1,28 @@ +// Copyright (c) Mapbox, Inc. +// Licensed under the MIT License. + +import { z } from 'zod'; + +const OptimizationSchema = z.object({ + type: z.string().describe('Type of optimization applied'), + description: z.string().describe('Description of what was optimized'), + count: z.number().describe('Number of items affected by this optimization') +}); + +export const OptimizeStyleOutputSchema = z.object({ + optimizedStyle: z.record(z.unknown()).describe('The optimized Mapbox style'), + optimizations: z + .array(OptimizationSchema) + .describe('List of optimizations that were applied'), + summary: z.object({ + totalOptimizations: z + .number() + .describe('Total number of optimization operations performed'), + originalSize: z.number().describe('Original style size in bytes'), + optimizedSize: z.number().describe('Optimized style size in bytes'), + sizeSaved: z.number().describe('Bytes saved through optimization'), + percentReduction: z.number().describe('Percentage reduction in size') + }) +}); + +export type OptimizeStyleOutput = z.infer; diff --git a/src/tools/optimize-style-tool/OptimizeStyleTool.ts b/src/tools/optimize-style-tool/OptimizeStyleTool.ts new file mode 100644 index 0000000..2608074 --- /dev/null +++ b/src/tools/optimize-style-tool/OptimizeStyleTool.ts @@ -0,0 +1,413 @@ +// Copyright (c) Mapbox, Inc. +// Licensed under the MIT License. + +import { z } from 'zod'; +import { BaseTool } from '../BaseTool.js'; +import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js'; +import { OptimizeStyleInputSchema } from './OptimizeStyleTool.input.schema.js'; +import { + OptimizeStyleOutputSchema, + type OptimizeStyleOutput +} from './OptimizeStyleTool.output.schema.js'; + +type Optimization = { + type: string; + description: string; + count: number; +}; + +/** + * OptimizeStyleTool - Optimizes Mapbox styles by removing redundancies and simplifying structure + * + * Performs various optimizations on Mapbox style JSON to reduce file size and improve performance. + */ +export class OptimizeStyleTool extends BaseTool< + typeof OptimizeStyleInputSchema, + typeof OptimizeStyleOutputSchema +> { + readonly name = 'optimize_style_tool'; + readonly description = + 'Optimizes Mapbox styles by removing unused sources, duplicate layers, and simplifying expressions'; + readonly annotations = { + title: 'Optimize Style Tool', + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + openWorldHint: false + }; + + constructor() { + super({ + inputSchema: OptimizeStyleInputSchema, + outputSchema: OptimizeStyleOutputSchema + }); + } + + protected async execute( + input: z.infer + ): Promise { + try { + // Parse style if it's a string + let style: any; + if (typeof input.style === 'string') { + try { + style = JSON.parse(input.style); + } catch (parseError) { + return { + content: [ + { + type: 'text', + text: `Error parsing style: ${(parseError as Error).message}` + } + ], + isError: true + }; + } + } else { + style = JSON.parse(JSON.stringify(input.style)); // Deep clone + } + + // Determine which optimizations to apply + const requestedOptimizations = input.optimizations || [ + 'remove-unused-sources', + 'remove-duplicate-layers', + 'simplify-expressions', + 'remove-empty-layers', + 'consolidate-filters' + ]; + + // Track optimizations performed + const optimizations: Optimization[] = []; + + // Calculate original size + const originalSize = JSON.stringify(style).length; + + // Apply each optimization + for (const optimization of requestedOptimizations) { + switch (optimization) { + case 'remove-unused-sources': + optimizations.push(this.removeUnusedSources(style)); + break; + case 'remove-duplicate-layers': + optimizations.push(this.removeDuplicateLayers(style)); + break; + case 'simplify-expressions': + optimizations.push(this.simplifyExpressions(style)); + break; + case 'remove-empty-layers': + optimizations.push(this.removeEmptyLayers(style)); + break; + case 'consolidate-filters': + optimizations.push(this.consolidateFilters(style)); + break; + } + } + + // Calculate optimized size + const optimizedSize = JSON.stringify(style).length; + const sizeSaved = originalSize - optimizedSize; + const percentReduction = + originalSize > 0 ? (sizeSaved / originalSize) * 100 : 0; + + const result: OptimizeStyleOutput = { + optimizedStyle: style, + optimizations: optimizations.filter((opt) => opt.count > 0), + summary: { + totalOptimizations: optimizations.reduce( + (sum, opt) => sum + opt.count, + 0 + ), + originalSize, + optimizedSize, + sizeSaved, + percentReduction: Math.round(percentReduction * 100) / 100 + } + }; + + const validatedResult = OptimizeStyleOutputSchema.parse(result); + + return { + content: [ + { + type: 'text', + text: JSON.stringify(validatedResult, null, 2) + } + ], + structuredContent: validatedResult, + isError: false + }; + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : String(error); + this.log('error', `${this.name}: ${errorMessage}`); + + return { + content: [{ type: 'text', text: `Error: ${errorMessage}` }], + isError: true + }; + } + } + + /** + * Remove sources that are not referenced by any layer + */ + private removeUnusedSources(style: any): Optimization { + if (!style.sources || !style.layers) { + return { + type: 'remove-unused-sources', + description: 'No unused sources found', + count: 0 + }; + } + + // Get all source IDs used by layers + const usedSources = new Set(); + for (const layer of style.layers) { + if (layer.source) { + usedSources.add(layer.source); + } + } + + // Find unused sources + const unusedSources: string[] = []; + for (const sourceId of Object.keys(style.sources)) { + if (!usedSources.has(sourceId)) { + unusedSources.push(sourceId); + } + } + + // Remove unused sources + for (const sourceId of unusedSources) { + delete style.sources[sourceId]; + } + + return { + type: 'remove-unused-sources', + description: `Removed ${unusedSources.length} unused source(s): ${unusedSources.join(', ')}`, + count: unusedSources.length + }; + } + + /** + * Remove layers that are exact duplicates + */ + private removeDuplicateLayers(style: any): Optimization { + if (!style.layers || style.layers.length === 0) { + return { + type: 'remove-duplicate-layers', + description: 'No duplicate layers found', + count: 0 + }; + } + + const seen = new Map(); + const duplicates: string[] = []; + const filteredLayers: any[] = []; + + for (const layer of style.layers) { + const { id, ...layerWithoutId } = layer; + const layerHash = JSON.stringify(layerWithoutId); + + if (seen.has(layerHash)) { + duplicates.push(id); + } else { + seen.set(layerHash, layer); + filteredLayers.push(layer); + } + } + + style.layers = filteredLayers; + + return { + type: 'remove-duplicate-layers', + description: + duplicates.length > 0 + ? `Removed ${duplicates.length} duplicate layer(s): ${duplicates.join(', ')}` + : 'No duplicate layers found', + count: duplicates.length + }; + } + + /** + * Simplify expressions where possible + */ + private simplifyExpressions(style: any): Optimization { + if (!style.layers) { + return { + type: 'simplify-expressions', + description: 'No expressions to simplify', + count: 0 + }; + } + + let simplifiedCount = 0; + + for (const layer of style.layers) { + // Simplify filter expressions + if (layer.filter) { + const simplified = this.simplifyExpression(layer.filter); + if (JSON.stringify(simplified) !== JSON.stringify(layer.filter)) { + layer.filter = simplified; + simplifiedCount++; + } + } + + // Simplify paint property expressions + if (layer.paint) { + for (const [key, value] of Object.entries(layer.paint)) { + if (Array.isArray(value)) { + const simplified = this.simplifyExpression(value); + if (JSON.stringify(simplified) !== JSON.stringify(value)) { + layer.paint[key] = simplified; + simplifiedCount++; + } + } + } + } + + // Simplify layout property expressions + if (layer.layout) { + for (const [key, value] of Object.entries(layer.layout)) { + if (Array.isArray(value)) { + const simplified = this.simplifyExpression(value); + if (JSON.stringify(simplified) !== JSON.stringify(value)) { + layer.layout[key] = simplified; + simplifiedCount++; + } + } + } + } + } + + return { + type: 'simplify-expressions', + description: + simplifiedCount > 0 + ? `Simplified ${simplifiedCount} expression(s)` + : 'No expressions simplified', + count: simplifiedCount + }; + } + + /** + * Simplify a single expression + */ + private simplifyExpression(expr: any): any { + if (!Array.isArray(expr)) { + return expr; + } + + const [operator, ...args] = expr; + + // Simplify ["all", true] -> true + if (operator === 'all' && args.length === 1 && args[0] === true) { + return true; + } + + // Simplify ["any", false] -> false + if (operator === 'any' && args.length === 1 && args[0] === false) { + return false; + } + + // Simplify ["!", false] -> true + if (operator === '!' && args.length === 1 && args[0] === false) { + return true; + } + + // Simplify ["!", true] -> false + if (operator === '!' && args.length === 1 && args[0] === true) { + return false; + } + + // Recursively simplify nested expressions + return [operator, ...args.map((arg) => this.simplifyExpression(arg))]; + } + + /** + * Remove layers with no visible properties + */ + private removeEmptyLayers(style: any): Optimization { + if (!style.layers || style.layers.length === 0) { + return { + type: 'remove-empty-layers', + description: 'No empty layers found', + count: 0 + }; + } + + const emptyLayers: string[] = []; + const filteredLayers: any[] = []; + + for (const layer of style.layers) { + const isEmpty = + (!layer.paint || Object.keys(layer.paint).length === 0) && + (!layer.layout || Object.keys(layer.layout).length === 0) && + layer.type !== 'background'; + + if (isEmpty) { + emptyLayers.push(layer.id); + } else { + filteredLayers.push(layer); + } + } + + style.layers = filteredLayers; + + return { + type: 'remove-empty-layers', + description: + emptyLayers.length > 0 + ? `Removed ${emptyLayers.length} empty layer(s): ${emptyLayers.join(', ')}` + : 'No empty layers found', + count: emptyLayers.length + }; + } + + /** + * Consolidate similar filter expressions (basic implementation) + */ + private consolidateFilters(style: any): Optimization { + if (!style.layers || style.layers.length < 2) { + return { + type: 'consolidate-filters', + description: 'No filters to consolidate', + count: 0 + }; + } + + // This is a simplified implementation + // A full implementation would identify layers with similar filters + // and potentially merge them or extract common filter logic + + let consolidatedCount = 0; + + // For now, we'll just identify layers with identical filters + const filterGroups = new Map(); + + for (const layer of style.layers) { + if (layer.filter) { + const filterStr = JSON.stringify(layer.filter); + if (!filterGroups.has(filterStr)) { + filterGroups.set(filterStr, []); + } + filterGroups.get(filterStr)!.push(layer.id); + } + } + + // Count groups with multiple layers (could be consolidated) + for (const [, layerIds] of filterGroups) { + if (layerIds.length > 1) { + consolidatedCount++; + } + } + + return { + type: 'consolidate-filters', + description: + consolidatedCount > 0 + ? `Found ${consolidatedCount} group(s) of layers with identical filters that could be consolidated` + : 'No filters to consolidate', + count: consolidatedCount + }; + } +} diff --git a/src/tools/toolRegistry.ts b/src/tools/toolRegistry.ts index 087b595..f903745 100644 --- a/src/tools/toolRegistry.ts +++ b/src/tools/toolRegistry.ts @@ -14,6 +14,7 @@ import { GetMapboxDocSourceTool } from './get-mapbox-doc-source-tool/GetMapboxDo import { GetReferenceTool } from './get-reference-tool/GetReferenceTool.js'; import { ListStylesTool } from './list-styles-tool/ListStylesTool.js'; import { ListTokensTool } from './list-tokens-tool/ListTokensTool.js'; +import { OptimizeStyleTool } from './optimize-style-tool/OptimizeStyleTool.js'; import { PreviewStyleTool } from './preview-style-tool/PreviewStyleTool.js'; import { RetrieveStyleTool } from './retrieve-style-tool/RetrieveStyleTool.js'; import { StyleBuilderTool } from './style-builder-tool/StyleBuilderTool.js'; @@ -32,6 +33,7 @@ export const ALL_TOOLS = [ new PreviewStyleTool(), new StyleBuilderTool(), new GeojsonPreviewTool(), + new OptimizeStyleTool(), new CreateTokenTool({ httpRequest }), new ListTokensTool({ httpRequest }), new BoundingBoxTool(), diff --git a/test/tools/__snapshots__/tool-naming-convention.test.ts.snap b/test/tools/__snapshots__/tool-naming-convention.test.ts.snap index ff8cefb..59bce03 100644 --- a/test/tools/__snapshots__/tool-naming-convention.test.ts.snap +++ b/test/tools/__snapshots__/tool-naming-convention.test.ts.snap @@ -67,6 +67,11 @@ exports[`Tool Naming Convention > should maintain consistent tool list (snapshot "description": "List Mapbox access tokens for the authenticated user with optional filtering and pagination. Returns metadata for all tokens (public and secret), but the actual token value is only included for public tokens (secret token values are omitted for security). When using pagination, the "start" parameter must be obtained from the "next_start" field of the previous response (it is not a token ID)", "toolName": "list_tokens_tool", }, + { + "className": "OptimizeStyleTool", + "description": "Optimizes Mapbox styles by removing unused sources, duplicate layers, and simplifying expressions", + "toolName": "optimize_style_tool", + }, { "className": "PreviewStyleTool", "description": "Generate preview URL for a Mapbox style using an existing public token", diff --git a/test/tools/optimize-style-tool/OptimizeStyleTool.test.ts b/test/tools/optimize-style-tool/OptimizeStyleTool.test.ts new file mode 100644 index 0000000..f9d7855 --- /dev/null +++ b/test/tools/optimize-style-tool/OptimizeStyleTool.test.ts @@ -0,0 +1,505 @@ +// Copyright (c) Mapbox, Inc. +// Licensed under the MIT License. + +import { describe, it, expect, beforeEach } from 'vitest'; +import { OptimizeStyleTool } from '../../../src/tools/optimize-style-tool/OptimizeStyleTool.js'; + +describe('OptimizeStyleTool', () => { + let tool: OptimizeStyleTool; + + beforeEach(() => { + tool = new OptimizeStyleTool(); + }); + + describe('tool metadata', () => { + it('should have correct name and description', () => { + expect(tool.name).toBe('optimize_style_tool'); + expect(tool.description).toBe( + 'Optimizes Mapbox styles by removing unused sources, duplicate layers, and simplifying expressions' + ); + }); + + it('should have correct annotations', () => { + expect(tool.annotations).toEqual({ + title: 'Optimize Style Tool', + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + openWorldHint: false + }); + }); + }); + + describe('basic optimization', () => { + it('should optimize a simple style', async () => { + const style = { + version: 8, + sources: {}, + layers: [] + }; + + const result = await tool.run({ style }); + expect(result.isError).toBe(false); + + const parsed = JSON.parse(result.content[0].text); + expect(parsed.optimizedStyle).toBeDefined(); + expect(parsed.optimizations).toBeDefined(); + expect(parsed.summary).toBeDefined(); + }); + + it('should handle JSON string input', async () => { + const style = { + version: 8, + sources: {}, + layers: [] + }; + + const result = await tool.run({ style: JSON.stringify(style) }); + expect(result.isError).toBe(false); + + const parsed = JSON.parse(result.content[0].text); + expect(parsed.optimizedStyle).toBeDefined(); + }); + }); + + describe('remove-unused-sources optimization', () => { + it('should remove unused sources', async () => { + const style = { + version: 8, + sources: { + 'used-source': { type: 'vector' }, + 'unused-source': { type: 'vector' } + }, + layers: [ + { + id: 'layer1', + type: 'fill', + source: 'used-source' + } + ] + }; + + const result = await tool.run({ + style, + optimizations: ['remove-unused-sources'] + }); + expect(result.isError).toBe(false); + + const parsed = JSON.parse(result.content[0].text); + expect(parsed.optimizedStyle.sources).toHaveProperty('used-source'); + expect(parsed.optimizedStyle.sources).not.toHaveProperty('unused-source'); + expect(parsed.optimizations).toHaveLength(1); + expect(parsed.optimizations[0].count).toBe(1); + }); + + it('should not remove any sources when all are used', async () => { + const style = { + version: 8, + sources: { + source1: { type: 'vector' }, + source2: { type: 'vector' } + }, + layers: [ + { id: 'layer1', type: 'fill', source: 'source1' }, + { id: 'layer2', type: 'line', source: 'source2' } + ] + }; + + const result = await tool.run({ + style, + optimizations: ['remove-unused-sources'] + }); + expect(result.isError).toBe(false); + + const parsed = JSON.parse(result.content[0].text); + expect(parsed.optimizations).toHaveLength(0); + }); + }); + + describe('remove-duplicate-layers optimization', () => { + it('should remove duplicate layers', async () => { + const style = { + version: 8, + sources: {}, + layers: [ + { + id: 'layer1', + type: 'fill', + paint: { 'fill-color': '#ff0000' } + }, + { + id: 'layer2', + type: 'fill', + paint: { 'fill-color': '#ff0000' } + }, + { + id: 'layer3', + type: 'line', + paint: { 'line-color': '#0000ff' } + } + ] + }; + + const result = await tool.run({ + style, + optimizations: ['remove-duplicate-layers'] + }); + expect(result.isError).toBe(false); + + const parsed = JSON.parse(result.content[0].text); + expect(parsed.optimizedStyle.layers).toHaveLength(2); + expect(parsed.optimizations[0].count).toBe(1); + }); + + it('should not remove any layers when all are unique', async () => { + const style = { + version: 8, + sources: {}, + layers: [ + { id: 'layer1', type: 'fill', paint: { 'fill-color': '#ff0000' } }, + { id: 'layer2', type: 'fill', paint: { 'fill-color': '#00ff00' } } + ] + }; + + const result = await tool.run({ + style, + optimizations: ['remove-duplicate-layers'] + }); + expect(result.isError).toBe(false); + + const parsed = JSON.parse(result.content[0].text); + expect(parsed.optimizedStyle.layers).toHaveLength(2); + expect(parsed.optimizations).toHaveLength(0); + }); + }); + + describe('simplify-expressions optimization', () => { + it('should simplify ["all", true] to true', async () => { + const style = { + version: 8, + sources: {}, + layers: [ + { + id: 'layer1', + type: 'fill', + filter: ['all', true], + paint: {} + } + ] + }; + + const result = await tool.run({ + style, + optimizations: ['simplify-expressions'] + }); + expect(result.isError).toBe(false); + + const parsed = JSON.parse(result.content[0].text); + expect(parsed.optimizedStyle.layers[0].filter).toBe(true); + expect(parsed.optimizations[0].count).toBeGreaterThan(0); + }); + + it('should simplify ["any", false] to false', async () => { + const style = { + version: 8, + sources: {}, + layers: [ + { + id: 'layer1', + type: 'fill', + filter: ['any', false], + paint: {} + } + ] + }; + + const result = await tool.run({ + style, + optimizations: ['simplify-expressions'] + }); + expect(result.isError).toBe(false); + + const parsed = JSON.parse(result.content[0].text); + expect(parsed.optimizedStyle.layers[0].filter).toBe(false); + }); + + it('should simplify ["!", false] to true', async () => { + const style = { + version: 8, + sources: {}, + layers: [ + { + id: 'layer1', + type: 'fill', + filter: ['!', false], + paint: {} + } + ] + }; + + const result = await tool.run({ + style, + optimizations: ['simplify-expressions'] + }); + expect(result.isError).toBe(false); + + const parsed = JSON.parse(result.content[0].text); + expect(parsed.optimizedStyle.layers[0].filter).toBe(true); + }); + + it('should simplify ["!", true] to false', async () => { + const style = { + version: 8, + sources: {}, + layers: [ + { + id: 'layer1', + type: 'fill', + filter: ['!', true], + paint: {} + } + ] + }; + + const result = await tool.run({ + style, + optimizations: ['simplify-expressions'] + }); + expect(result.isError).toBe(false); + + const parsed = JSON.parse(result.content[0].text); + expect(parsed.optimizedStyle.layers[0].filter).toBe(false); + }); + + it('should simplify paint property expressions', async () => { + const style = { + version: 8, + sources: {}, + layers: [ + { + id: 'layer1', + type: 'fill', + paint: { + 'fill-opacity': ['all', true] + } + } + ] + }; + + const result = await tool.run({ + style, + optimizations: ['simplify-expressions'] + }); + expect(result.isError).toBe(false); + + const parsed = JSON.parse(result.content[0].text); + expect(parsed.optimizedStyle.layers[0].paint['fill-opacity']).toBe(true); + }); + }); + + describe('remove-empty-layers optimization', () => { + it('should remove layers with no paint or layout properties', async () => { + const style = { + version: 8, + sources: {}, + layers: [ + { + id: 'empty-layer', + type: 'fill', + paint: {}, + layout: {} + }, + { + id: 'non-empty-layer', + type: 'fill', + paint: { 'fill-color': '#ff0000' } + } + ] + }; + + const result = await tool.run({ + style, + optimizations: ['remove-empty-layers'] + }); + expect(result.isError).toBe(false); + + const parsed = JSON.parse(result.content[0].text); + expect(parsed.optimizedStyle.layers).toHaveLength(1); + expect(parsed.optimizedStyle.layers[0].id).toBe('non-empty-layer'); + expect(parsed.optimizations[0].count).toBe(1); + }); + + it('should not remove background layers even if empty', async () => { + const style = { + version: 8, + sources: {}, + layers: [ + { + id: 'background', + type: 'background', + paint: {}, + layout: {} + } + ] + }; + + const result = await tool.run({ + style, + optimizations: ['remove-empty-layers'] + }); + expect(result.isError).toBe(false); + + const parsed = JSON.parse(result.content[0].text); + expect(parsed.optimizedStyle.layers).toHaveLength(1); + expect(parsed.optimizations).toHaveLength(0); + }); + }); + + describe('consolidate-filters optimization', () => { + it('should identify layers with identical filters', async () => { + const style = { + version: 8, + sources: {}, + layers: [ + { + id: 'layer1', + type: 'fill', + filter: ['==', ['get', 'type'], 'water'], + paint: {} + }, + { + id: 'layer2', + type: 'line', + filter: ['==', ['get', 'type'], 'water'], + paint: {} + } + ] + }; + + const result = await tool.run({ + style, + optimizations: ['consolidate-filters'] + }); + expect(result.isError).toBe(false); + + const parsed = JSON.parse(result.content[0].text); + // Should identify the group of layers with identical filters + expect(parsed.optimizations[0].count).toBeGreaterThan(0); + }); + }); + + describe('combined optimizations', () => { + it('should apply multiple optimizations', async () => { + const style = { + version: 8, + sources: { + used: { type: 'vector' }, + unused: { type: 'vector' } + }, + layers: [ + { + id: 'layer1', + type: 'fill', + source: 'used', + filter: ['all', true], + paint: { 'fill-color': '#ff0000' } + }, + { + id: 'layer2', + type: 'fill', + source: 'used', + filter: ['all', true], + paint: { 'fill-color': '#ff0000' } + }, + { + id: 'empty', + type: 'fill', + paint: {}, + layout: {} + } + ] + }; + + const result = await tool.run({ style }); + expect(result.isError).toBe(false); + + const parsed = JSON.parse(result.content[0].text); + // Should have removed unused source, duplicate layer, empty layer, and simplified expressions + expect(parsed.optimizations.length).toBeGreaterThan(0); + expect(parsed.summary.totalOptimizations).toBeGreaterThan(0); + }); + }); + + describe('summary statistics', () => { + it('should calculate size savings', async () => { + const style = { + version: 8, + sources: { + unused: { type: 'vector' } + }, + layers: [] + }; + + const result = await tool.run({ style }); + expect(result.isError).toBe(false); + + const parsed = JSON.parse(result.content[0].text); + expect(parsed.summary.originalSize).toBeGreaterThan(0); + expect(parsed.summary.optimizedSize).toBeGreaterThan(0); + expect(parsed.summary.sizeSaved).toBeGreaterThanOrEqual(0); + expect(parsed.summary.percentReduction).toBeGreaterThanOrEqual(0); + }); + + it('should show percentage reduction', async () => { + const style = { + version: 8, + sources: { + unused1: { type: 'vector' }, + unused2: { type: 'vector' }, + unused3: { type: 'vector' } + }, + layers: [] + }; + + const result = await tool.run({ style }); + expect(result.isError).toBe(false); + + const parsed = JSON.parse(result.content[0].text); + expect(parsed.summary.percentReduction).toBeGreaterThan(0); + }); + }); + + describe('error handling', () => { + it('should handle invalid JSON string', async () => { + const result = await tool.run({ style: '{invalid json' }); + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('Error parsing style'); + }); + }); + + describe('no optimizations needed', () => { + it('should return zero optimizations for already optimized style', async () => { + const style = { + version: 8, + sources: { + 'vector-source': { type: 'vector' } + }, + layers: [ + { + id: 'layer1', + type: 'fill', + source: 'vector-source', + paint: { 'fill-color': '#ff0000' } + } + ] + }; + + const result = await tool.run({ style }); + expect(result.isError).toBe(false); + + const parsed = JSON.parse(result.content[0].text); + expect(parsed.summary.totalOptimizations).toBe(0); + expect(parsed.summary.sizeSaved).toBe(0); + }); + }); +});