Updated json condenser to handle nested array, added copy to clipboard button, and updated related tests
This commit is contained in:
		
							parent
							
								
									3f00795361
								
							
						
					
					
						commit
						d4e9f0e39d
					
				| @ -0,0 +1,106 @@ | ||||
| import { expect, test } from '@playwright/test'; | ||||
| 
 | ||||
| test.describe('Tool - Json data condenser', () => { | ||||
|   test.beforeEach(async ({ page }) => { | ||||
|     await page.goto('/json-data-condenser'); | ||||
|   }); | ||||
| 
 | ||||
|   test('Has correct title', async ({ page }) => { | ||||
|     await expect(page).toHaveTitle('Json data condenser - IT Tools'); | ||||
|   }); | ||||
| 
 | ||||
|   test('Condenses valid JSON input correctly', async ({ page }) => { | ||||
|     const validJson = JSON.stringify({ | ||||
|       users: [ | ||||
|         { id: 1, name: 'Alice' }, | ||||
|         { id: 2, name: 'Bob' }, | ||||
|         { id: 3, name: 'Charlie' }, | ||||
|         { id: 4, name: 'David', email: 'david@example.com' }, | ||||
|         { id: 5, name: 'Eve' }, | ||||
|       ], | ||||
|       status: 'active', | ||||
|     }, null, 2); | ||||
| 
 | ||||
|     await page.getByPlaceholder('Paste a JSON payload here...').fill(validJson); | ||||
|     await page.getByRole('button', { name: 'Condense JSON' }).click(); | ||||
| 
 | ||||
|     const outputTextarea = page.getByPlaceholder('Condensed JSON will appear here...'); | ||||
|     const outputText = await outputTextarea.inputValue(); | ||||
| 
 | ||||
|     expect(outputText).toContain('"status": "active"'); | ||||
|     expect(outputText).toContain('Alice'); | ||||
|     expect(outputText).toContain('David'); | ||||
|     expect(outputText).not.toContain('Bob'); | ||||
|     expect(outputText).not.toContain('Charlie'); | ||||
|     expect(outputText).not.toContain('Eve'); | ||||
|   }); | ||||
| 
 | ||||
|   test('Displays error on invalid JSON input', async ({ page }) => { | ||||
|     await page.getByPlaceholder('Paste a JSON payload here...').fill('{ invalid json '); | ||||
|     await page.getByRole('button', { name: 'Condense JSON' }).click(); | ||||
| 
 | ||||
|     await expect(page.getByText('Invalid JSON input. Please fix and try again.')).toBeVisible(); | ||||
|   }); | ||||
| 
 | ||||
|   test('Handles nested arrays of objects and preserves distinct structures', async ({ page }) => { | ||||
|     const nestedJson = JSON.stringify({ | ||||
|       data: { | ||||
|         results: [ | ||||
|           { | ||||
|             id: '1', | ||||
|             components: [ | ||||
|               { content_type: 'text', content: { format: 'markdown', text: 'Foo' } }, | ||||
|               { content_type: 'video', content: { video_id: 'v1', duration: '1:00', platform: 'yt' } }, | ||||
|               { content_type: 'image', content: { url: 'i.jpg', alt: 'Bar' } }, | ||||
|               { content_type: 'text', content: { format: 'markdown', text: 'Baz' } }, | ||||
|             ], | ||||
|           }, | ||||
|           { | ||||
|             id: '2', | ||||
|             components: [ | ||||
|               { content_type: 'code', content: { lang: 'js', code: 'x' } }, | ||||
|               { content_type: 'code', content: { lang: 'py', code: 'y' } }, | ||||
|             ], | ||||
|           }, | ||||
|         ], | ||||
|       }, | ||||
|     }, null, 2); | ||||
| 
 | ||||
|     await page.getByPlaceholder('Paste a JSON payload here...').fill(nestedJson); | ||||
|     await page.getByRole('button', { name: 'Condense JSON' }).click(); | ||||
| 
 | ||||
|     const outputTextarea = page.getByPlaceholder('Condensed JSON will appear here...'); | ||||
|     const outputText = await outputTextarea.inputValue(); | ||||
| 
 | ||||
|     expect(outputText).toContain('"format": "markdown"'); | ||||
|     expect(outputText).toContain('"video_id": "v1"'); | ||||
|     expect(outputText).toContain('"url": "i.jpg"'); | ||||
|     expect(outputText).toContain('"lang": "js"'); | ||||
| 
 | ||||
|     expect(outputText).not.toContain('"text": "Baz"'); | ||||
|     expect(outputText).not.toContain('"lang": "py"'); | ||||
|   }); | ||||
| 
 | ||||
|   test('Copies condensed JSON to clipboard', async ({ page }) => { | ||||
|     const validJson = JSON.stringify({ | ||||
|       items: [ | ||||
|         { id: 1, name: 'Foo' }, | ||||
|         { id: 2, name: 'Bar' }, | ||||
|         { id: 3, name: 'Baz', extra: true }, | ||||
|       ], | ||||
|     }); | ||||
| 
 | ||||
|     await page.getByPlaceholder('Paste a JSON payload here...').fill(validJson); | ||||
|     await page.getByRole('button', { name: 'Condense JSON' }).click(); | ||||
| 
 | ||||
|     // Click the copy button
 | ||||
|     await page.getByRole('button', { name: 'Copy' }).click(); | ||||
| 
 | ||||
|     // Read from clipboard
 | ||||
|     const clipboardText = await page.evaluate(() => navigator.clipboard.readText()); | ||||
| 
 | ||||
|     // Confirm clipboard contains condensed data
 | ||||
|     expect(clipboardText).toContain('"id": 1'); | ||||
|     expect(clipboardText).toContain('"name": "Foo"'); | ||||
|   }); | ||||
| }); | ||||
| @ -0,0 +1,108 @@ | ||||
| import { describe, expect, it } from 'vitest'; | ||||
| import { type JsonValue, condenseJsonStructures } from '../json-data-condenser.service'; | ||||
| 
 | ||||
| const asJsonValue = <T extends JsonValue>(val: T): T => val; | ||||
| 
 | ||||
| describe('condenseJsonStructures', () => { | ||||
|   it('removes duplicate object structures in an array but keeps unique ones', () => { | ||||
|     const input = asJsonValue({ | ||||
|       users: [ | ||||
|         { id: 1, name: 'Alice' }, | ||||
|         { id: 2, name: 'Bob' }, | ||||
|         { id: 3, name: 'Charlie' }, | ||||
|         { id: 4, name: 'David', email: 'david@example.com' }, | ||||
|         { id: 5, name: 'Eve' }, | ||||
|       ], | ||||
|       status: 'active', | ||||
|     }); | ||||
| 
 | ||||
|     const output = condenseJsonStructures(input); | ||||
| 
 | ||||
|     expect(output).toEqual({ | ||||
|       users: [ | ||||
|         { id: 1, name: 'Alice' }, | ||||
|         { id: 4, name: 'David', email: 'david@example.com' }, | ||||
|       ], | ||||
|       status: 'active', | ||||
|     }); | ||||
|   }); | ||||
| 
 | ||||
|   it('keeps all non-array values untouched', () => { | ||||
|     const input = asJsonValue({ | ||||
|       status: 'ok', | ||||
|       count: 5, | ||||
|       success: true, | ||||
|       metadata: { | ||||
|         source: 'api', | ||||
|         timestamp: '2025-06-27T12:00:00Z', | ||||
|       }, | ||||
|     }); | ||||
| 
 | ||||
|     const output = condenseJsonStructures(input); | ||||
| 
 | ||||
|     expect(output).toEqual(input); | ||||
|   }); | ||||
| 
 | ||||
|   it('keeps non-object array values untouched', () => { | ||||
|     const input = asJsonValue([1, 2, 3, 'a', true, null]); | ||||
| 
 | ||||
|     const output = condenseJsonStructures(input); | ||||
| 
 | ||||
|     expect(output).toEqual([1, 2, 3, 'a', true, null]); | ||||
|   }); | ||||
| 
 | ||||
|   it('returns primitive values unchanged', () => { | ||||
|     expect(condenseJsonStructures(42)).toBe(42); | ||||
|     expect(condenseJsonStructures('hello')).toBe('hello'); | ||||
|     expect(condenseJsonStructures(null)).toBe(null); | ||||
|     expect(condenseJsonStructures(true)).toBe(true); | ||||
|   }); | ||||
| 
 | ||||
|   it('handles nested object arrays and preserves unique structures', () => { | ||||
|     const input = asJsonValue({ | ||||
|       data: { | ||||
|         results: [ | ||||
|           { | ||||
|             id: '1', | ||||
|             components: [ | ||||
|               { content_type: 'text', content: { format: 'markdown', text: 'Foo' } }, | ||||
|               { content_type: 'video', content: { video_id: 'v1', duration: '1:00', platform: 'yt' } }, | ||||
|               { content_type: 'image', content: { url: 'i.jpg', alt: 'Bar' } }, | ||||
|               { content_type: 'text', content: { format: 'markdown', text: 'Baz' } }, | ||||
|             ], | ||||
|           }, | ||||
|           { | ||||
|             id: '2', | ||||
|             components: [ | ||||
|               { content_type: 'code', content: { lang: 'js', code: 'x' } }, | ||||
|               { content_type: 'code', content: { lang: 'py', code: 'y' } }, | ||||
|             ], | ||||
|           }, | ||||
|         ], | ||||
|       }, | ||||
|     }); | ||||
| 
 | ||||
|     const output = condenseJsonStructures(input); | ||||
| 
 | ||||
|     expect(output).toEqual({ | ||||
|       data: { | ||||
|         results: [ | ||||
|           { | ||||
|             id: '1', | ||||
|             components: [ | ||||
|               { content_type: 'text', content: { format: 'markdown', text: 'Foo' } }, | ||||
|               { content_type: 'video', content: { video_id: 'v1', duration: '1:00', platform: 'yt' } }, | ||||
|               { content_type: 'image', content: { url: 'i.jpg', alt: 'Bar' } }, | ||||
|             ], | ||||
|           }, | ||||
|           { | ||||
|             id: '2', | ||||
|             components: [ | ||||
|               { content_type: 'code', content: { lang: 'js', code: 'x' } }, | ||||
|             ], | ||||
|           }, | ||||
|         ], | ||||
|       }, | ||||
|     }); | ||||
|   }); | ||||
| }); | ||||
| @ -1,44 +0,0 @@ | ||||
| import { expect, test } from '@playwright/test'; | ||||
| 
 | ||||
| test.describe('Tool - Json data condenser', () => { | ||||
|   test.beforeEach(async ({ page }) => { | ||||
|     await page.goto('/json-data-condenser'); | ||||
|   }); | ||||
| 
 | ||||
|   test('Has correct title', async ({ page }) => { | ||||
|     await expect(page).toHaveTitle('Json data condenser - IT Tools'); | ||||
|   }); | ||||
| 
 | ||||
|   test('Condenses valid JSON input correctly', async ({ page }) => { | ||||
|     const validJson = JSON.stringify({ | ||||
|       users: [ | ||||
|         { id: 1, name: 'Alice' }, | ||||
|         { id: 2, name: 'Bob' }, | ||||
|         { id: 3, name: 'Charlie' }, | ||||
|         { id: 4, name: 'David', email: 'david@example.com' }, | ||||
|         { id: 5, name: 'Eve' }, | ||||
|       ], | ||||
|       status: 'active', | ||||
|     }, null, 2); | ||||
| 
 | ||||
|     await page.getByPlaceholder('Paste a JSON payload here...').fill(validJson); | ||||
|     await page.getByRole('button', { name: 'Condense JSON' }).click(); | ||||
| 
 | ||||
|     const outputTextarea = page.getByPlaceholder('Condensed JSON will appear here...'); | ||||
|     const outputText = await outputTextarea.inputValue(); | ||||
| 
 | ||||
|     expect(outputText).toContain('"status": "active"'); | ||||
|     expect(outputText).toContain('Alice'); | ||||
|     expect(outputText).toContain('David'); | ||||
|     expect(outputText).not.toContain('Bob'); | ||||
|     expect(outputText).not.toContain('Charlie'); | ||||
|     expect(outputText).not.toContain('Eve'); | ||||
|   }); | ||||
| 
 | ||||
|   test('Displays error on invalid JSON input', async ({ page }) => { | ||||
|     await page.getByPlaceholder('Paste a JSON payload here...').fill('{ invalid json '); | ||||
|     await page.getByRole('button', { name: 'Condense JSON' }).click(); | ||||
| 
 | ||||
|     await expect(page.getByText('Invalid JSON input. Please fix and try again.')).toBeVisible(); | ||||
|   }); | ||||
| }); | ||||
| @ -1,60 +0,0 @@ | ||||
| import { describe, expect, it } from 'vitest'; | ||||
| import { type JsonValue, condenseJsonStructures } from './json-data-condenser.service'; | ||||
| 
 | ||||
| const asJsonValue = <T extends JsonValue>(val: T): T => val; | ||||
| 
 | ||||
| describe('condenseJsonStructures', () => { | ||||
|   it('removes duplicate object structures in an array but keeps unique ones', () => { | ||||
|     const input = asJsonValue({ | ||||
|       users: [ | ||||
|         { id: 1, name: 'Alice' }, | ||||
|         { id: 2, name: 'Bob' }, | ||||
|         { id: 3, name: 'Charlie' }, | ||||
|         { id: 4, name: 'David', email: 'david@example.com' }, // unique structure
 | ||||
|         { id: 5, name: 'Eve' }, | ||||
|       ], | ||||
|       status: 'active', | ||||
|     }); | ||||
| 
 | ||||
|     const output = condenseJsonStructures(input); | ||||
| 
 | ||||
|     expect(output).toEqual({ | ||||
|       users: [ | ||||
|         { id: 1, name: 'Alice' }, | ||||
|         { id: 4, name: 'David', email: 'david@example.com' }, // kept due to extra key
 | ||||
|       ], | ||||
|       status: 'active', | ||||
|     }); | ||||
|   }); | ||||
| 
 | ||||
|   it('keeps all non-array values untouched', () => { | ||||
|     const input = asJsonValue({ | ||||
|       status: 'ok', | ||||
|       count: 5, | ||||
|       success: true, | ||||
|       metadata: { | ||||
|         source: 'api', | ||||
|         timestamp: '2025-06-27T12:00:00Z', | ||||
|       }, | ||||
|     }); | ||||
| 
 | ||||
|     const output = condenseJsonStructures(input); | ||||
| 
 | ||||
|     expect(output).toEqual(input); | ||||
|   }); | ||||
| 
 | ||||
|   it('keeps non-object array values untouched', () => { | ||||
|     const input = asJsonValue([1, 2, 3, 'a', true, null]); | ||||
| 
 | ||||
|     const output = condenseJsonStructures(input); | ||||
| 
 | ||||
|     expect(output).toEqual([1, 2, 3, 'a', true, null]); | ||||
|   }); | ||||
| 
 | ||||
|   it('returns primitive values unchanged', () => { | ||||
|     expect(condenseJsonStructures(42)).toBe(42); | ||||
|     expect(condenseJsonStructures('hello')).toBe('hello'); | ||||
|     expect(condenseJsonStructures(null)).toBe(null); | ||||
|     expect(condenseJsonStructures(true)).toBe(true); | ||||
|   }); | ||||
| }); | ||||
| @ -1,24 +1,57 @@ | ||||
| /** | ||||
|  * Represents any valid JSON value, including nested objects and arrays. | ||||
|  */ | ||||
| export type JsonValue = string | number | boolean | null | JsonValue[] | { [key: string]: JsonValue }; | ||||
| 
 | ||||
| function getKeySignature(obj: Record<string, any>): string { | ||||
|   // Create a normalized signature string of sorted keys
 | ||||
|   return Object.keys(obj).sort().join(','); | ||||
| /** | ||||
|  * Recursively generates a deep signature string for a given JSON value. | ||||
|  * This signature reflects the structure and key types of the value, | ||||
|  * and is used to detect and eliminate redundant object structures in arrays. | ||||
|  * | ||||
|  * @param value - The JSON value to generate a structural signature for. | ||||
|  * @returns A normalized string representing the structure of the value. | ||||
|  */ | ||||
| function getDeepKeySignature(value: JsonValue): string { | ||||
|   if (value === null || typeof value !== 'object') { | ||||
|     return typeof value; | ||||
|   } | ||||
| 
 | ||||
|   if (Array.isArray(value)) { | ||||
|     return `array<${value.map(getDeepKeySignature).join('|')}>`; | ||||
|   } | ||||
| 
 | ||||
|   const keys = Object.keys(value).sort(); | ||||
|   const nested = keys | ||||
|     .map(key => `${key}:${getDeepKeySignature((value as Record<string, JsonValue>)[key])}`) | ||||
|     .join(','); | ||||
| 
 | ||||
|   return `{${nested}}`; | ||||
| } | ||||
| 
 | ||||
| /** | ||||
|  * Recursively condenses a JSON object by removing redundant objects | ||||
|  * with identical structures (key shape and nested types) in arrays. | ||||
|  * | ||||
|  * For arrays of objects, only one representative per unique structure | ||||
|  * is kept. Objects with additional or differing key paths are preserved. | ||||
|  * | ||||
|  * Non-array values and primitives are returned unchanged. | ||||
|  * | ||||
|  * @param data - The JSON value to condense. | ||||
|  * @returns A condensed version of the original JSON value. | ||||
|  */ | ||||
| export function condenseJsonStructures(data: JsonValue): JsonValue { | ||||
|   // Handle primitive values
 | ||||
|   if (typeof data !== 'object' || data === null) { | ||||
|   if (data === null || typeof data !== 'object') { | ||||
|     return data; | ||||
|   } | ||||
| 
 | ||||
|   // Handle arrays
 | ||||
|   if (Array.isArray(data)) { | ||||
|     const seenSignatures = new Set<string>(); | ||||
|     const result: JsonValue[] = []; | ||||
| 
 | ||||
|     for (const item of data) { | ||||
|       if (typeof item === 'object' && item !== null && !Array.isArray(item)) { | ||||
|         const signature = getKeySignature(item as Record<string, JsonValue>); | ||||
|       if (item && typeof item === 'object' && !Array.isArray(item)) { | ||||
|         const signature = getDeepKeySignature(item); | ||||
|         if (!seenSignatures.has(signature)) { | ||||
|           seenSignatures.add(signature); | ||||
|           result.push(condenseJsonStructures(item)); | ||||
| @ -32,7 +65,6 @@ export function condenseJsonStructures(data: JsonValue): JsonValue { | ||||
|     return result; | ||||
|   } | ||||
| 
 | ||||
|   // Handle plain objects
 | ||||
|   const result: Record<string, JsonValue> = {}; | ||||
|   for (const key in data) { | ||||
|     result[key] = condenseJsonStructures(data[key]); | ||||
|  | ||||
| @ -5,6 +5,7 @@ import { condenseJsonStructures } from './json-data-condenser.service'; | ||||
| const rawJson = ref(''); | ||||
| const condensedJson = ref(''); | ||||
| const error = ref<string | null>(null); | ||||
| const copySuccess = ref(false); | ||||
| 
 | ||||
| function condense() { | ||||
|   error.value = null; | ||||
| @ -19,6 +20,17 @@ function condense() { | ||||
|     error.value = 'Invalid JSON input. Please fix and try again.'; | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| async function copyToClipboard() { | ||||
|   try { | ||||
|     await navigator.clipboard.writeText(condensedJson.value); | ||||
|     copySuccess.value = true; | ||||
|     setTimeout(() => (copySuccess.value = false), 2000); | ||||
|   } | ||||
|   catch { | ||||
|     error.value = 'Failed to copy to clipboard.'; | ||||
|   } | ||||
| } | ||||
| </script> | ||||
| 
 | ||||
| <template> | ||||
| @ -51,6 +63,12 @@ function condense() { | ||||
|         rows="12" | ||||
|         readonly multiline monospace raw-text | ||||
|       /> | ||||
| 
 | ||||
|       <div class="mt-4 flex"> | ||||
|         <c-button @click="copyToClipboard"> | ||||
|           {{ copySuccess ? 'Copied!' : 'Copy' }} | ||||
|         </c-button> | ||||
|       </div> | ||||
|     </div> | ||||
| 
 | ||||
|     <!-- Error Display --> | ||||
|  | ||||
		Loading…
	
		Reference in New Issue
	
	Block a user