diff --git a/.editorconfig b/.editorconfig
new file mode 100644
index 0000000..a186cd2
--- /dev/null
+++ b/.editorconfig
@@ -0,0 +1,18 @@
+root = true
+
+[*]
+charset = utf-8
+end_of_line = lf
+indent_size = 4
+indent_style = space
+insert_final_newline = true
+trim_trailing_whitespace = true
+
+[*.md]
+trim_trailing_whitespace = false
+
+[*.{yml,yaml}]
+indent_size = 2
+
+[compose.yaml]
+indent_size = 4
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 4a98f09..05b5551 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,6 +5,26 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
+## [1.3.0] - 2025-12-15
+
+### Added
+
+- **Sort Lines**: New text line manipulation tool
+ - Sort alphabetically (A-Z, Z-A)
+ - Natural sort for alphanumeric strings (file1, file2, file10)
+ - Numeric sort (ascending/descending)
+ - Sort by line length
+ - Reverse line order
+ - Remove duplicates (dedupe)
+ - Shuffle/randomize lines
+ - Options: case sensitive, trim whitespace, remove empty lines
+
+## [1.2.1] - 2025-12-15
+
+### Fixed
+
+- Code Editor: Fix PHP parse error when loading the page (` 'tools.diff',
'icon' => 'diff',
],
+ [
+ 'name' => 'Sort Lines',
+ 'description' => 'Sort, deduplicate, reverse, and shuffle lines',
+ 'route' => 'tools.sort-lines',
+ 'icon' => 'sort',
+ ],
];
return view('home', compact('tools'));
@@ -266,4 +272,9 @@ public function diff(): View
{
return view('tools.diff');
}
+
+ public function sortLines(): View
+ {
+ return view('tools.sort-lines');
+ }
}
diff --git a/phpunit.xml b/phpunit.xml
new file mode 100644
index 0000000..d703241
--- /dev/null
+++ b/phpunit.xml
@@ -0,0 +1,35 @@
+
+
Sort, deduplicate, reverse, and shuffle text lines
+', $response->json('result'));
+ // Code block has language class
+ $this->assertMatchesRegularExpression('/]*>/', $response->json('result'));
+ }
+}
diff --git a/tests/Feature/Api/SqlApiTest.php b/tests/Feature/Api/SqlApiTest.php
new file mode 100644
index 0000000..f5e4d70
--- /dev/null
+++ b/tests/Feature/Api/SqlApiTest.php
@@ -0,0 +1,81 @@
+postJson('/api/v1/sql/format', [
+ 'sql' => 'SELECT id, name FROM users WHERE active = 1',
+ 'mode' => 'format',
+ ]);
+
+ $response->assertStatus(200)
+ ->assertJson(['success' => true]);
+
+ $result = $response->json('result');
+ $this->assertStringContainsString("SELECT", $result);
+ $this->assertGreaterThan(1, substr_count($result, "\n"));
+ }
+
+ public function test_compress_sql(): void
+ {
+ $response = $this->postJson('/api/v1/sql/format', [
+ 'sql' => "SELECT\n id,\n name\nFROM\n users",
+ 'mode' => 'compress',
+ ]);
+
+ $response->assertStatus(200)
+ ->assertJson(['success' => true]);
+
+ $result = $response->json('result');
+ $this->assertEquals(0, substr_count($result, "\n"));
+ }
+
+ public function test_highlight_sql(): void
+ {
+ $response = $this->postJson('/api/v1/sql/format', [
+ 'sql' => 'SELECT id FROM users',
+ 'mode' => 'highlight',
+ ]);
+
+ $response->assertStatus(200)
+ ->assertJson(['success' => true]);
+
+ // Highlighted should contain HTML
+ $this->assertStringContainsString('<', $response->json('result'));
+ }
+
+ public function test_default_mode_is_format(): void
+ {
+ $response = $this->postJson('/api/v1/sql/format', [
+ 'sql' => 'SELECT id FROM users',
+ ]);
+
+ $response->assertStatus(200);
+ // Should be formatted (has newlines)
+ $this->assertGreaterThan(0, substr_count($response->json('result'), "\n"));
+ }
+
+ public function test_validation_requires_sql(): void
+ {
+ $response = $this->postJson('/api/v1/sql/format', []);
+
+ $response->assertStatus(422)
+ ->assertJsonValidationErrors(['sql']);
+ }
+
+ public function test_validation_requires_valid_mode(): void
+ {
+ $response = $this->postJson('/api/v1/sql/format', [
+ 'sql' => 'SELECT 1',
+ 'mode' => 'invalid',
+ ]);
+
+ $response->assertStatus(422)
+ ->assertJsonValidationErrors(['mode']);
+ }
+}
diff --git a/tests/Feature/Api/YamlApiTest.php b/tests/Feature/Api/YamlApiTest.php
new file mode 100644
index 0000000..8dc7c22
--- /dev/null
+++ b/tests/Feature/Api/YamlApiTest.php
@@ -0,0 +1,79 @@
+postJson('/api/v1/yaml/convert', [
+ 'input' => "name: John\nage: 30",
+ 'direction' => 'yaml-to-json',
+ ]);
+
+ $response->assertStatus(200)
+ ->assertJson(['success' => true]);
+
+ $result = json_decode($response->json('result'), true);
+ $this->assertEquals('John', $result['name']);
+ $this->assertEquals(30, $result['age']);
+ }
+
+ public function test_json_to_yaml(): void
+ {
+ $response = $this->postJson('/api/v1/yaml/convert', [
+ 'input' => '{"name": "John", "age": 30}',
+ 'direction' => 'json-to-yaml',
+ ]);
+
+ $response->assertStatus(200)
+ ->assertJson(['success' => true]);
+
+ $this->assertStringContainsString('name: John', $response->json('result'));
+ }
+
+ public function test_invalid_yaml_returns_error(): void
+ {
+ $response = $this->postJson('/api/v1/yaml/convert', [
+ 'input' => "invalid: yaml: syntax:",
+ 'direction' => 'yaml-to-json',
+ ]);
+
+ $response->assertStatus(422)
+ ->assertJson(['success' => false]);
+ }
+
+ public function test_invalid_json_returns_error(): void
+ {
+ $response = $this->postJson('/api/v1/yaml/convert', [
+ 'input' => '{invalid}',
+ 'direction' => 'json-to-yaml',
+ ]);
+
+ $response->assertStatus(422)
+ ->assertJson(['success' => false]);
+ }
+
+ public function test_validation_requires_input(): void
+ {
+ $response = $this->postJson('/api/v1/yaml/convert', [
+ 'direction' => 'yaml-to-json',
+ ]);
+
+ $response->assertStatus(422)
+ ->assertJsonValidationErrors(['input']);
+ }
+
+ public function test_validation_requires_valid_direction(): void
+ {
+ $response = $this->postJson('/api/v1/yaml/convert', [
+ 'input' => 'test',
+ 'direction' => 'invalid',
+ ]);
+
+ $response->assertStatus(422)
+ ->assertJsonValidationErrors(['direction']);
+ }
+}
diff --git a/tests/Feature/ExampleTest.php b/tests/Feature/ExampleTest.php
new file mode 100644
index 0000000..8364a84
--- /dev/null
+++ b/tests/Feature/ExampleTest.php
@@ -0,0 +1,19 @@
+get('/');
+
+ $response->assertStatus(200);
+ }
+}
diff --git a/tests/Feature/WebRoutesTest.php b/tests/Feature/WebRoutesTest.php
index d99e52e..b4c1203 100644
--- a/tests/Feature/WebRoutesTest.php
+++ b/tests/Feature/WebRoutesTest.php
@@ -41,6 +41,7 @@ public function test_home_page_displays_all_tools(): void
$response->assertSee('JWT Decoder');
$response->assertSee('Timestamp Converter');
$response->assertSee('Diff Checker');
+ $response->assertSee('Sort Lines');
}
public function test_home_page_has_tool_links(): void
@@ -70,6 +71,7 @@ public function test_home_page_has_tool_links(): void
$response->assertSee('href="' . route('tools.jwt') . '"', false);
$response->assertSee('href="' . route('tools.timestamp') . '"', false);
$response->assertSee('href="' . route('tools.diff') . '"', false);
+ $response->assertSee('href="' . route('tools.sort-lines') . '"', false);
}
public function test_csv_tool_page_loads(): void
@@ -516,9 +518,30 @@ public function test_diff_tool_has_required_elements(): void
$response->assertSee('Compare');
}
+ public function test_sort_lines_page_loads(): void
+ {
+ $response = $this->get('/tools/sort-lines');
+
+ $response->assertStatus(200);
+ $response->assertSee('Sort Lines');
+ $response->assertSee('Sort, deduplicate, reverse, and shuffle text lines');
+ }
+
+ public function test_sort_lines_has_required_elements(): void
+ {
+ $response = $this->get('/tools/sort-lines');
+
+ $response->assertStatus(200);
+ $response->assertSee('Input Text');
+ $response->assertSee('Sort Options');
+ $response->assertSee('Sort A-Z');
+ $response->assertSee('Remove Duplicates');
+ $response->assertSee('Shuffle');
+ }
+
public function test_all_pages_have_navigation(): void
{
- $pages = ['/', '/tools/csv', '/tools/yaml', '/tools/markdown', '/tools/sql', '/tools/base64', '/tools/uuid', '/tools/hash', '/tools/url', '/tools/code-editor', '/tools/regex', '/tools/base-converter', '/tools/slug-generator', '/tools/color-picker', '/tools/qr-code', '/tools/html-entity', '/tools/text-case', '/tools/password', '/tools/lorem', '/tools/cron', '/tools/jwt', '/tools/timestamp', '/tools/diff'];
+ $pages = ['/', '/tools/csv', '/tools/yaml', '/tools/markdown', '/tools/sql', '/tools/base64', '/tools/uuid', '/tools/hash', '/tools/url', '/tools/code-editor', '/tools/regex', '/tools/base-converter', '/tools/slug-generator', '/tools/color-picker', '/tools/qr-code', '/tools/html-entity', '/tools/text-case', '/tools/password', '/tools/lorem', '/tools/cron', '/tools/jwt', '/tools/timestamp', '/tools/diff', '/tools/sort-lines'];
foreach ($pages as $page) {
$response = $this->get($page);
@@ -530,7 +553,7 @@ public function test_all_pages_have_navigation(): void
public function test_all_pages_have_theme_toggle(): void
{
- $pages = ['/', '/tools/csv', '/tools/yaml', '/tools/markdown', '/tools/sql', '/tools/base64', '/tools/uuid', '/tools/hash', '/tools/url', '/tools/code-editor', '/tools/regex', '/tools/base-converter', '/tools/slug-generator', '/tools/color-picker', '/tools/qr-code', '/tools/html-entity', '/tools/text-case', '/tools/password', '/tools/lorem', '/tools/cron', '/tools/jwt', '/tools/timestamp', '/tools/diff'];
+ $pages = ['/', '/tools/csv', '/tools/yaml', '/tools/markdown', '/tools/sql', '/tools/base64', '/tools/uuid', '/tools/hash', '/tools/url', '/tools/code-editor', '/tools/regex', '/tools/base-converter', '/tools/slug-generator', '/tools/color-picker', '/tools/qr-code', '/tools/html-entity', '/tools/text-case', '/tools/password', '/tools/lorem', '/tools/cron', '/tools/jwt', '/tools/timestamp', '/tools/diff', '/tools/sort-lines'];
foreach ($pages as $page) {
$response = $this->get($page);
@@ -543,7 +566,7 @@ public function test_all_pages_have_theme_toggle(): void
public function test_all_pages_load_vite_assets(): void
{
// Code editor uses standalone template without Vite
- $pages = ['/', '/tools/csv', '/tools/yaml', '/tools/markdown', '/tools/sql', '/tools/base64', '/tools/uuid', '/tools/hash', '/tools/url', '/tools/regex', '/tools/base-converter', '/tools/slug-generator', '/tools/color-picker', '/tools/qr-code', '/tools/html-entity', '/tools/text-case', '/tools/password', '/tools/lorem', '/tools/cron', '/tools/jwt', '/tools/timestamp', '/tools/diff'];
+ $pages = ['/', '/tools/csv', '/tools/yaml', '/tools/markdown', '/tools/sql', '/tools/base64', '/tools/uuid', '/tools/hash', '/tools/url', '/tools/regex', '/tools/base-converter', '/tools/slug-generator', '/tools/color-picker', '/tools/qr-code', '/tools/html-entity', '/tools/text-case', '/tools/password', '/tools/lorem', '/tools/cron', '/tools/jwt', '/tools/timestamp', '/tools/diff', '/tools/sort-lines'];
foreach ($pages as $page) {
$response = $this->get($page);
@@ -556,7 +579,7 @@ public function test_all_pages_load_vite_assets(): void
public function test_all_tool_pages_have_back_link(): void
{
// Code editor uses standalone template with home link instead of back
- $toolPages = ['/tools/csv', '/tools/yaml', '/tools/markdown', '/tools/sql', '/tools/base64', '/tools/uuid', '/tools/hash', '/tools/url', '/tools/regex', '/tools/base-converter', '/tools/slug-generator', '/tools/color-picker', '/tools/qr-code', '/tools/html-entity', '/tools/text-case', '/tools/password', '/tools/lorem', '/tools/cron', '/tools/jwt', '/tools/timestamp', '/tools/diff'];
+ $toolPages = ['/tools/csv', '/tools/yaml', '/tools/markdown', '/tools/sql', '/tools/base64', '/tools/uuid', '/tools/hash', '/tools/url', '/tools/regex', '/tools/base-converter', '/tools/slug-generator', '/tools/color-picker', '/tools/qr-code', '/tools/html-entity', '/tools/text-case', '/tools/password', '/tools/lorem', '/tools/cron', '/tools/jwt', '/tools/timestamp', '/tools/diff', '/tools/sort-lines'];
foreach ($toolPages as $page) {
$response = $this->get($page);
@@ -609,7 +632,7 @@ public function test_api_routes_reject_get_requests(): void
public function test_pages_have_csrf_token(): void
{
- $pages = ['/tools/csv', '/tools/yaml', '/tools/markdown', '/tools/sql', '/tools/base64', '/tools/uuid', '/tools/hash', '/tools/url', '/tools/code-editor', '/tools/regex', '/tools/base-converter', '/tools/slug-generator', '/tools/color-picker', '/tools/qr-code', '/tools/html-entity', '/tools/text-case', '/tools/password', '/tools/lorem', '/tools/cron', '/tools/jwt', '/tools/timestamp', '/tools/diff'];
+ $pages = ['/tools/csv', '/tools/yaml', '/tools/markdown', '/tools/sql', '/tools/base64', '/tools/uuid', '/tools/hash', '/tools/url', '/tools/code-editor', '/tools/regex', '/tools/base-converter', '/tools/slug-generator', '/tools/color-picker', '/tools/qr-code', '/tools/html-entity', '/tools/text-case', '/tools/password', '/tools/lorem', '/tools/cron', '/tools/jwt', '/tools/timestamp', '/tools/diff', '/tools/sort-lines'];
foreach ($pages as $page) {
$response = $this->get($page);
diff --git a/tests/TestCase.php b/tests/TestCase.php
new file mode 100644
index 0000000..fe1ffc2
--- /dev/null
+++ b/tests/TestCase.php
@@ -0,0 +1,10 @@
+assertTrue(true);
+ }
+}
diff --git a/tests/Unit/Services/Base64ServiceTest.php b/tests/Unit/Services/Base64ServiceTest.php
new file mode 100644
index 0000000..2497254
--- /dev/null
+++ b/tests/Unit/Services/Base64ServiceTest.php
@@ -0,0 +1,137 @@
+service = new Base64Service();
+ }
+
+ public function test_encode_simple_string(): void
+ {
+ $result = $this->service->encode('Hello World');
+ $this->assertEquals('SGVsbG8gV29ybGQ=', $result);
+ }
+
+ public function test_encode_empty_string(): void
+ {
+ $result = $this->service->encode('');
+ $this->assertEquals('', $result);
+ }
+
+ public function test_encode_unicode(): void
+ {
+ $result = $this->service->encode('こんにちは');
+ $this->assertEquals('44GT44KT44Gr44Gh44Gv', $result);
+ }
+
+ public function test_encode_special_characters(): void
+ {
+ $result = $this->service->encode('Hello & ');
+ $decoded = base64_decode($result);
+ $this->assertEquals('Hello & ', $decoded);
+ }
+
+ public function test_decode_valid_base64(): void
+ {
+ $result = $this->service->decode('SGVsbG8gV29ybGQ=');
+
+ $this->assertTrue($result['success']);
+ $this->assertEquals('Hello World', $result['result']);
+ $this->assertFalse($result['is_binary']);
+ }
+
+ public function test_decode_invalid_base64(): void
+ {
+ $result = $this->service->decode('!!!invalid!!!');
+
+ $this->assertFalse($result['success']);
+ $this->assertStringContainsString('Invalid', $result['error']);
+ }
+
+ public function test_decode_detects_binary_content(): void
+ {
+ // Create base64 of binary data (null bytes)
+ $binary = base64_encode("\x00\x01\x02\x03");
+ $result = $this->service->decode($binary);
+
+ $this->assertTrue($result['success']);
+ $this->assertTrue($result['is_binary']);
+ }
+
+ public function test_decode_unicode(): void
+ {
+ $result = $this->service->decode('44GT44KT44Gr44Gh44Gv');
+
+ $this->assertTrue($result['success']);
+ $this->assertEquals('こんにちは', $result['result']);
+ $this->assertFalse($result['is_binary']);
+ }
+
+ public function test_encode_file_creates_data_url(): void
+ {
+ $content = 'Hello World';
+ $mimeType = 'text/plain';
+
+ $result = $this->service->encodeFile($content, $mimeType);
+
+ $this->assertStringStartsWith('data:text/plain;base64,', $result);
+ $this->assertStringContainsString('SGVsbG8gV29ybGQ=', $result);
+ }
+
+ public function test_encode_file_with_image_mime(): void
+ {
+ $content = 'fake image data';
+ $mimeType = 'image/png';
+
+ $result = $this->service->encodeFile($content, $mimeType);
+
+ $this->assertStringStartsWith('data:image/png;base64,', $result);
+ }
+
+ public function test_decode_data_url_valid(): void
+ {
+ $dataUrl = 'data:text/plain;base64,SGVsbG8gV29ybGQ=';
+ $result = $this->service->decodeDataUrl($dataUrl);
+
+ $this->assertTrue($result['success']);
+ $this->assertEquals('text/plain', $result['mime_type']);
+ $this->assertEquals('Hello World', $result['content']);
+ $this->assertEquals(11, $result['size']);
+ }
+
+ public function test_decode_data_url_invalid_format(): void
+ {
+ $result = $this->service->decodeDataUrl('not a data url');
+
+ $this->assertFalse($result['success']);
+ $this->assertStringContainsString('Invalid data URL', $result['error']);
+ }
+
+ public function test_decode_data_url_invalid_base64(): void
+ {
+ $dataUrl = 'data:text/plain;base64,!!!invalid!!!';
+ $result = $this->service->decodeDataUrl($dataUrl);
+
+ $this->assertFalse($result['success']);
+ $this->assertStringContainsString('Invalid Base64', $result['error']);
+ }
+
+ public function test_roundtrip_encode_decode(): void
+ {
+ $original = 'Test string with special chars: @#$%^&*()';
+ $encoded = $this->service->encode($original);
+ $decoded = $this->service->decode($encoded);
+
+ $this->assertTrue($decoded['success']);
+ $this->assertEquals($original, $decoded['result']);
+ }
+}
diff --git a/tests/Unit/Services/CsvConverterServiceTest.php b/tests/Unit/Services/CsvConverterServiceTest.php
new file mode 100644
index 0000000..e8700bd
--- /dev/null
+++ b/tests/Unit/Services/CsvConverterServiceTest.php
@@ -0,0 +1,114 @@
+service = new CsvConverterService();
+ }
+
+ public function test_parse_simple_csv(): void
+ {
+ $csv = "name,age\nJohn,30\nJane,25";
+ $result = $this->service->parse($csv);
+
+ $this->assertCount(3, $result);
+ $this->assertEquals(['name', 'age'], $result[0]);
+ $this->assertEquals(['John', '30'], $result[1]);
+ $this->assertEquals(['Jane', '25'], $result[2]);
+ }
+
+ public function test_parse_with_semicolon_delimiter(): void
+ {
+ $csv = "name;age\nJohn;30";
+ $result = $this->service->parse($csv, ';');
+
+ $this->assertCount(2, $result);
+ $this->assertEquals(['name', 'age'], $result[0]);
+ $this->assertEquals(['John', '30'], $result[1]);
+ }
+
+ public function test_to_json_with_headers(): void
+ {
+ $csv = "name,age\nJohn,30\nJane,25";
+ $result = $this->service->toJson($csv, ',', true);
+ $decoded = json_decode($result, true);
+
+ $this->assertCount(2, $decoded);
+ $this->assertEquals('John', $decoded[0]['name']);
+ $this->assertEquals('30', $decoded[0]['age']);
+ $this->assertEquals('Jane', $decoded[1]['name']);
+ }
+
+ public function test_to_json_without_headers(): void
+ {
+ $csv = "John,30\nJane,25";
+ $result = $this->service->toJson($csv, ',', false);
+ $decoded = json_decode($result, true);
+
+ $this->assertCount(2, $decoded);
+ $this->assertEquals(['John', '30'], $decoded[0]);
+ }
+
+ public function test_to_json_empty_csv(): void
+ {
+ $result = $this->service->toJson('', ',', true);
+ $this->assertEquals('[]', $result);
+ }
+
+ public function test_to_sql_with_headers(): void
+ {
+ $csv = "name,age\nJohn,30";
+ $result = $this->service->toSql($csv, 'users', ',', true);
+
+ $this->assertStringContainsString('INSERT INTO `users`', $result);
+ $this->assertStringContainsString('`name`', $result);
+ $this->assertStringContainsString('`age`', $result);
+ $this->assertStringContainsString("'John'", $result);
+ $this->assertStringContainsString("'30'", $result);
+ }
+
+ public function test_to_sql_escapes_quotes(): void
+ {
+ $csv = "name\nO'Brien";
+ $result = $this->service->toSql($csv, 'users', ',', true);
+
+ $this->assertStringContainsString("O\\'Brien", $result);
+ }
+
+ public function test_to_sql_handles_null_values(): void
+ {
+ $csv = "name,age\nJohn,";
+ $result = $this->service->toSql($csv, 'users', ',', true);
+
+ $this->assertStringContainsString('NULL', $result);
+ }
+
+ public function test_to_php_array_with_headers(): void
+ {
+ $csv = "name,age\nJohn,30";
+ $result = $this->service->toPhpArray($csv, ',', true);
+
+ $this->assertStringContainsString("'name' => 'John'", $result);
+ // Numeric values are output as numbers, not strings
+ $this->assertStringContainsString("'age' => 30", $result);
+ }
+
+ public function test_to_php_array_without_headers(): void
+ {
+ $csv = "John,30";
+ $result = $this->service->toPhpArray($csv, ',', false);
+
+ $this->assertStringContainsString("'John'", $result);
+ // Numeric values are output as numbers
+ $this->assertStringContainsString("30", $result);
+ }
+}
diff --git a/tests/Unit/Services/CsvEdgeCasesTest.php b/tests/Unit/Services/CsvEdgeCasesTest.php
new file mode 100644
index 0000000..46f13f0
--- /dev/null
+++ b/tests/Unit/Services/CsvEdgeCasesTest.php
@@ -0,0 +1,330 @@
+service = new CsvConverterService();
+ }
+
+ // ==================== Quoted Fields ====================
+
+ public function test_quoted_field_containing_comma(): void
+ {
+ $csv = "name,address\nJohn,\"123 Main St, Apt 4\"";
+ $result = $this->service->parse($csv);
+
+ $this->assertCount(2, $result);
+ $this->assertEquals('123 Main St, Apt 4', $result[1][1]);
+ }
+
+ public function test_quoted_field_containing_newline(): void
+ {
+ // Note: This tests if the parser handles embedded newlines in quoted fields
+ $csv = "name,note\nJohn,\"Line 1\nLine 2\"";
+ $result = $this->service->parse($csv);
+
+ // The basic str_getcsv splits on newlines first, so this tests current behavior
+ $this->assertGreaterThanOrEqual(2, count($result));
+ }
+
+ public function test_double_quotes_inside_quoted_field(): void
+ {
+ $csv = "name,quote\nJohn,\"He said \"\"Hello\"\"\"";
+ $result = $this->service->parse($csv);
+
+ $this->assertCount(2, $result);
+ // CSV standard: doubled quotes become single quotes
+ $this->assertStringContainsString('Hello', $result[1][1]);
+ }
+
+ public function test_empty_quoted_field(): void
+ {
+ $csv = "name,value\nJohn,\"\"";
+ $result = $this->service->parse($csv);
+
+ $this->assertCount(2, $result);
+ $this->assertEquals('', $result[1][1]);
+ }
+
+ // ==================== Unicode Characters ====================
+
+ public function test_unicode_emoji(): void
+ {
+ $csv = "name,mood\nJohn,😀";
+ $json = $this->service->toJson($csv, ',', true);
+ $decoded = json_decode($json, true);
+
+ $this->assertEquals('😀', $decoded[0]['mood']);
+ }
+
+ public function test_unicode_cjk_characters(): void
+ {
+ $csv = "name,greeting\n田中,こんにちは";
+ $json = $this->service->toJson($csv, ',', true);
+ $decoded = json_decode($json, true);
+
+ $this->assertEquals('田中', $decoded[0]['name']);
+ $this->assertEquals('こんにちは', $decoded[0]['greeting']);
+ }
+
+ public function test_unicode_arabic(): void
+ {
+ $csv = "name,greeting\nأحمد,مرحبا";
+ $json = $this->service->toJson($csv, ',', true);
+ $decoded = json_decode($json, true);
+
+ $this->assertEquals('أحمد', $decoded[0]['name']);
+ $this->assertEquals('مرحبا', $decoded[0]['greeting']);
+ }
+
+ public function test_unicode_in_sql_output(): void
+ {
+ $csv = "name\n田中";
+ $sql = $this->service->toSql($csv, 'users', ',', true);
+
+ $this->assertStringContainsString('田中', $sql);
+ }
+
+ // ==================== Delimiters ====================
+
+ public function test_tab_delimiter(): void
+ {
+ $csv = "name\tage\nJohn\t30";
+ $result = $this->service->parse($csv, "\t");
+
+ $this->assertCount(2, $result);
+ $this->assertEquals(['name', 'age'], $result[0]);
+ $this->assertEquals(['John', '30'], $result[1]);
+ }
+
+ public function test_pipe_delimiter(): void
+ {
+ $csv = "name|age\nJohn|30";
+ $result = $this->service->parse($csv, '|');
+
+ $this->assertCount(2, $result);
+ $this->assertEquals('John', $result[1][0]);
+ }
+
+ public function test_delimiter_in_quoted_field(): void
+ {
+ $csv = "name,value\nJohn,\"a,b,c\"";
+ $result = $this->service->parse($csv);
+
+ $this->assertEquals('a,b,c', $result[1][1]);
+ }
+
+ // ==================== Edge Cases with Empty/Whitespace ====================
+
+ public function test_empty_csv(): void
+ {
+ $result = $this->service->parse('');
+ $this->assertEmpty($result);
+ }
+
+ public function test_whitespace_only_csv(): void
+ {
+ $result = $this->service->parse(" \n \n ");
+ $this->assertEmpty($result);
+ }
+
+ public function test_single_row_no_data(): void
+ {
+ $csv = "name,age";
+ $json = $this->service->toJson($csv, ',', true);
+ $decoded = json_decode($json, true);
+
+ // With only headers and hasHeaders=true, but only 1 row,
+ // it falls through to non-header mode and returns the row as data
+ $this->assertCount(1, $decoded);
+ $this->assertEquals(['name', 'age'], $decoded[0]);
+ }
+
+ public function test_trailing_newlines(): void
+ {
+ $csv = "name,age\nJohn,30\n\n\n";
+ $result = $this->service->parse($csv);
+
+ $this->assertCount(2, $result);
+ }
+
+ public function test_leading_whitespace_in_values(): void
+ {
+ $csv = "name,age\n John , 30 ";
+ $result = $this->service->parse($csv);
+
+ // Values should preserve whitespace (trimming is user's choice)
+ $this->assertCount(2, $result);
+ }
+
+ // ==================== Inconsistent Data ====================
+
+ public function test_inconsistent_column_count_more_columns(): void
+ {
+ $csv = "name,age\nJohn,30,extra";
+ $result = $this->service->parse($csv);
+
+ $this->assertCount(2, $result);
+ $this->assertCount(3, $result[1]); // Extra column preserved
+ }
+
+ public function test_inconsistent_column_count_fewer_columns(): void
+ {
+ $csv = "name,age,city\nJohn,30";
+ $json = $this->service->toJson($csv, ',', true);
+ $decoded = json_decode($json, true);
+
+ $this->assertCount(1, $decoded);
+ $this->assertEquals('John', $decoded[0]['name']);
+ $this->assertEquals('30', $decoded[0]['age']);
+ $this->assertNull($decoded[0]['city']);
+ }
+
+ public function test_trailing_comma(): void
+ {
+ $csv = "name,age,\nJohn,30,";
+ $result = $this->service->parse($csv);
+
+ $this->assertCount(2, $result);
+ // Trailing comma creates empty field
+ $this->assertCount(3, $result[0]);
+ }
+
+ // ==================== Large Data ====================
+
+ public function test_very_long_field(): void
+ {
+ $longValue = str_repeat('a', 10000);
+ $csv = "name,data\nJohn,{$longValue}";
+ $result = $this->service->parse($csv);
+
+ $this->assertEquals($longValue, $result[1][1]);
+ }
+
+ public function test_many_columns(): void
+ {
+ $headers = implode(',', range(1, 100));
+ $values = implode(',', array_fill(0, 100, 'x'));
+ $csv = "{$headers}\n{$values}";
+
+ $result = $this->service->parse($csv);
+
+ $this->assertCount(100, $result[0]);
+ $this->assertCount(100, $result[1]);
+ }
+
+ public function test_many_rows(): void
+ {
+ $rows = ["name,age"];
+ for ($i = 0; $i < 1000; $i++) {
+ $rows[] = "User{$i},{$i}";
+ }
+ $csv = implode("\n", $rows);
+
+ $result = $this->service->parse($csv);
+
+ $this->assertCount(1001, $result);
+ }
+
+ // ==================== Special Characters ====================
+
+ public function test_backslash_in_value(): void
+ {
+ $csv = "path\nC:\\Users\\John";
+ $result = $this->service->parse($csv);
+
+ $this->assertStringContainsString('\\', $result[1][0]);
+ }
+
+ public function test_sql_special_chars_escaped(): void
+ {
+ $csv = "name\nO'Brien";
+ $sql = $this->service->toSql($csv, 'users', ',', true);
+
+ // Single quotes should be escaped
+ $this->assertStringContainsString("\\'", $sql);
+ }
+
+ public function test_html_special_chars_preserved(): void
+ {
+ $csv = "code\n";
+ $json = $this->service->toJson($csv, ',', true);
+ $decoded = json_decode($json, true);
+
+ // HTML chars should be preserved in JSON (escaping is for display)
+ $this->assertStringContainsString('