1
0
Fork 0
mirror of https://github.com/CorentinTh/it-tools.git synced 2025-08-07 22:45:19 +02:00

Updated json condenser to handle nested array, added copy to clipboard button, and updated related tests

This commit is contained in:
Bryan Liao 2025-07-17 18:14:02 -07:00
parent 3f00795361
commit d4e9f0e39d
6 changed files with 273 additions and 113 deletions

View file

@ -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"');
});
});

View file

@ -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' } },
],
},
],
},
});
});
});

View file

@ -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();
});
});

View file

@ -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);
});
});

View file

@ -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 }; 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 * Recursively generates a deep signature string for a given JSON value.
return Object.keys(obj).sort().join(','); * 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 { export function condenseJsonStructures(data: JsonValue): JsonValue {
// Handle primitive values if (data === null || typeof data !== 'object') {
if (typeof data !== 'object' || data === null) {
return data; return data;
} }
// Handle arrays
if (Array.isArray(data)) { if (Array.isArray(data)) {
const seenSignatures = new Set<string>(); const seenSignatures = new Set<string>();
const result: JsonValue[] = []; const result: JsonValue[] = [];
for (const item of data) { for (const item of data) {
if (typeof item === 'object' && item !== null && !Array.isArray(item)) { if (item && typeof item === 'object' && !Array.isArray(item)) {
const signature = getKeySignature(item as Record<string, JsonValue>); const signature = getDeepKeySignature(item);
if (!seenSignatures.has(signature)) { if (!seenSignatures.has(signature)) {
seenSignatures.add(signature); seenSignatures.add(signature);
result.push(condenseJsonStructures(item)); result.push(condenseJsonStructures(item));
@ -32,7 +65,6 @@ export function condenseJsonStructures(data: JsonValue): JsonValue {
return result; return result;
} }
// Handle plain objects
const result: Record<string, JsonValue> = {}; const result: Record<string, JsonValue> = {};
for (const key in data) { for (const key in data) {
result[key] = condenseJsonStructures(data[key]); result[key] = condenseJsonStructures(data[key]);

View file

@ -5,6 +5,7 @@ import { condenseJsonStructures } from './json-data-condenser.service';
const rawJson = ref(''); const rawJson = ref('');
const condensedJson = ref(''); const condensedJson = ref('');
const error = ref<string | null>(null); const error = ref<string | null>(null);
const copySuccess = ref(false);
function condense() { function condense() {
error.value = null; error.value = null;
@ -19,6 +20,17 @@ function condense() {
error.value = 'Invalid JSON input. Please fix and try again.'; 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> </script>
<template> <template>
@ -51,6 +63,12 @@ function condense() {
rows="12" rows="12"
readonly multiline monospace raw-text readonly multiline monospace raw-text
/> />
<div class="mt-4 flex">
<c-button @click="copyToClipboard">
{{ copySuccess ? 'Copied!' : 'Copy' }}
</c-button>
</div>
</div> </div>
<!-- Error Display --> <!-- Error Display -->