├── public
├── favicon.ico
├── logo192.png
├── logo512.png
├── robots.txt
├── favicon-32.png
├── manifest.json
└── icon.svg
├── src
├── vite-env.d.ts
├── index.tsx
├── setupTests.ts
├── reportWebVitals.ts
├── App.css
├── index.css
├── components
│ ├── FileUpload.tsx
│ ├── Statistics.tsx
│ ├── ExportPanel.tsx
│ ├── BatchProcessor.tsx
│ └── KLVBuilder.tsx
├── logo.svg
├── tests
│ ├── helpers
│ │ └── testUtils.ts
│ ├── components
│ │ ├── FileUpload.test.tsx
│ │ ├── Statistics.test.tsx
│ │ ├── ExportPanel.test.tsx
│ │ ├── KLVBuilder.test.tsx
│ │ ├── BatchProcessor.test.tsx
│ │ └── App.test.tsx
│ └── utils
│ │ └── KLVParser.test.ts
├── utils
│ └── KLVParser.ts
└── App.tsx
├── .claude
└── settings.local.json
├── tsconfig.node.json
├── index.html
├── tsconfig.json
├── LICENSE
├── .github
└── workflows
│ ├── deploy.yml
│ └── ci.yml
├── vite.config.ts
├── package.json
├── .gitignore
├── CLAUDE.md
└── README.md
/public/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ohmrefresh/klv-extractor/main/public/favicon.ico
--------------------------------------------------------------------------------
/public/logo192.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ohmrefresh/klv-extractor/main/public/logo192.png
--------------------------------------------------------------------------------
/public/logo512.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ohmrefresh/klv-extractor/main/public/logo512.png
--------------------------------------------------------------------------------
/public/robots.txt:
--------------------------------------------------------------------------------
1 | # https://www.robotstxt.org/robotstxt.html
2 | User-agent: *
3 | Disallow:
4 |
--------------------------------------------------------------------------------
/public/favicon-32.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ohmrefresh/klv-extractor/main/public/favicon-32.png
--------------------------------------------------------------------------------
/src/vite-env.d.ts:
--------------------------------------------------------------------------------
1 | ///
2 |
3 | declare const __BUILD_DATE__: string;
4 | declare const __APP_VERSION__: string;
5 |
--------------------------------------------------------------------------------
/.claude/settings.local.json:
--------------------------------------------------------------------------------
1 | {
2 | "permissions": {
3 | "allow": [
4 | "Bash(npm test:*)",
5 | "Bash(npm run test:ci:*)"
6 | ],
7 | "deny": [],
8 | "ask": []
9 | }
10 | }
--------------------------------------------------------------------------------
/tsconfig.node.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "composite": true,
4 | "skipLibCheck": true,
5 | "module": "ESNext",
6 | "moduleResolution": "bundler",
7 | "allowSyntheticDefaultImports": true,
8 | "strict": true
9 | },
10 | "include": ["vite.config.ts"]
11 | }
12 |
--------------------------------------------------------------------------------
/src/index.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import ReactDOM from 'react-dom/client';
3 | import './index.css';
4 | import App from './App';
5 |
6 | const root = ReactDOM.createRoot(
7 | document.getElementById('root') as HTMLElement
8 | );
9 | root.render(
10 |
11 |
12 |
13 | );
--------------------------------------------------------------------------------
/src/setupTests.ts:
--------------------------------------------------------------------------------
1 | // jest-dom adds custom jest matchers for asserting on DOM nodes.
2 | // allows you to do things like:
3 | // expect(element).toHaveTextContent(/react/i)
4 | // learn more: https://github.com/testing-library/jest-dom
5 | import '@testing-library/jest-dom';
6 |
7 | // Ensure proper DOM setup for React Testing Library
8 | import { configure } from '@testing-library/react';
9 | configure({ testIdAttribute: 'data-testid' });
--------------------------------------------------------------------------------
/src/reportWebVitals.ts:
--------------------------------------------------------------------------------
1 | import type { Metric } from 'web-vitals';
2 |
3 | const reportWebVitals = (onPerfEntry?: (metric: Metric) => void) => {
4 | if (onPerfEntry && onPerfEntry instanceof Function) {
5 | import('web-vitals').then(({ onCLS, onINP, onFCP, onLCP, onTTFB }) => {
6 | onCLS(onPerfEntry);
7 | onINP(onPerfEntry);
8 | onFCP(onPerfEntry);
9 | onLCP(onPerfEntry);
10 | onTTFB(onPerfEntry);
11 | });
12 | }
13 | };
14 |
15 | export default reportWebVitals;
--------------------------------------------------------------------------------
/public/manifest.json:
--------------------------------------------------------------------------------
1 | {
2 | "short_name": "KLV Extractor",
3 | "name": "KLV Data Extraction Suite",
4 | "description": "Complete toolkit for KLV data processing, parsing, and analysis",
5 | "icons": [
6 | {
7 | "src": "favicon.ico",
8 | "sizes": "64x64 32x32 24x24 16x16",
9 | "type": "image/x-icon"
10 | },
11 | {
12 | "src": "logo192.png",
13 | "type": "image/png",
14 | "sizes": "192x192"
15 | },
16 | {
17 | "src": "logo512.png",
18 | "type": "image/png",
19 | "sizes": "512x512"
20 | }
21 | ],
22 | "start_url": ".",
23 | "display": "standalone",
24 | "theme_color": "#3B82F6",
25 | "background_color": "#F9FAFB"
26 | }
27 |
--------------------------------------------------------------------------------
/src/App.css:
--------------------------------------------------------------------------------
1 | .App {
2 | text-align: center;
3 | }
4 |
5 | .App-logo {
6 | height: 40vmin;
7 | pointer-events: none;
8 | }
9 |
10 | @media (prefers-reduced-motion: no-preference) {
11 | .App-logo {
12 | animation: App-logo-spin infinite 20s linear;
13 | }
14 | }
15 |
16 | .App-header {
17 | background-color: #282c34;
18 | min-height: 100vh;
19 | display: flex;
20 | flex-direction: column;
21 | align-items: center;
22 | justify-content: center;
23 | font-size: calc(10px + 2vmin);
24 | color: white;
25 | }
26 |
27 | .App-link {
28 | color: #61dafb;
29 | }
30 |
31 | @keyframes App-logo-spin {
32 | from {
33 | transform: rotate(0deg);
34 | }
35 | to {
36 | transform: rotate(360deg);
37 | }
38 | }
39 |
--------------------------------------------------------------------------------
/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 | KLV Extractor - Data Extraction Suite
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
--------------------------------------------------------------------------------
/src/index.css:
--------------------------------------------------------------------------------
1 | @tailwind base;
2 | @tailwind components;
3 | @tailwind utilities;
4 |
5 | body {
6 | margin: 0;
7 | font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
8 | 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
9 | sans-serif;
10 | -webkit-font-smoothing: antialiased;
11 | -moz-osx-font-smoothing: grayscale;
12 | }
13 |
14 | code {
15 | font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
16 | monospace;
17 | }
18 |
19 | /* Custom scrollbar */
20 | ::-webkit-scrollbar {
21 | width: 8px;
22 | }
23 |
24 | ::-webkit-scrollbar-track {
25 | background: #f1f1f1;
26 | }
27 |
28 | ::-webkit-scrollbar-thumb {
29 | background: #c1c1c1;
30 | border-radius: 4px;
31 | }
32 |
33 | ::-webkit-scrollbar-thumb:hover {
34 | background: #a8a8a8;
35 | }
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "ES2020",
4 | "useDefineForClassFields": true,
5 | "lib": ["ES2020", "DOM", "DOM.Iterable"],
6 | "module": "ESNext",
7 | "skipLibCheck": true,
8 |
9 | /* Bundler mode */
10 | "moduleResolution": "bundler",
11 | "allowImportingTsExtensions": true,
12 | "resolveJsonModule": true,
13 | "isolatedModules": true,
14 | "noEmit": true,
15 | "jsx": "react-jsx",
16 |
17 | /* Linting */
18 | "strict": true,
19 | "noUnusedLocals": false,
20 | "noUnusedParameters": false,
21 | "noFallthroughCasesInSwitch": true,
22 | "allowJs": true,
23 | "esModuleInterop": true,
24 | "allowSyntheticDefaultImports": true,
25 | "forceConsistentCasingInFileNames": true,
26 |
27 | /* Vitest */
28 | "types": ["vitest/globals"]
29 | },
30 | "include": ["src"],
31 | "references": [{ "path": "./tsconfig.node.json" }]
32 | }
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2025 Ohm
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/.github/workflows/deploy.yml:
--------------------------------------------------------------------------------
1 | name: Deploy to GitHub Pages
2 |
3 | on:
4 | push:
5 | branches: [ main ]
6 | pull_request:
7 | branches: [ main ]
8 |
9 | permissions:
10 | contents: read
11 | pages: write
12 | id-token: write
13 |
14 | concurrency:
15 | group: "pages"
16 | cancel-in-progress: false
17 |
18 | jobs:
19 | build:
20 | runs-on: ubuntu-latest
21 |
22 | steps:
23 | - name: Checkout
24 | uses: actions/checkout@v4
25 |
26 | - name: Setup Node.js
27 | uses: actions/setup-node@v4
28 | with:
29 | node-version: '20'
30 | cache: 'npm'
31 |
32 | - name: Install dependencies
33 | run: npm ci
34 |
35 | - name: Run tests
36 | run: npm run test:ci
37 |
38 | - name: Build
39 | run: npm run build
40 | env:
41 | BASE: /klv-extractor/
42 |
43 | - name: Setup Pages
44 | uses: actions/configure-pages@v4
45 |
46 | - name: Upload artifact
47 | uses: actions/upload-pages-artifact@v3
48 | with:
49 | path: ./build
50 |
51 | deploy:
52 | environment:
53 | name: github-pages
54 | url: ${{ steps.deployment.outputs.page_url }}
55 | runs-on: ubuntu-latest
56 | needs: build
57 | if: github.ref == 'refs/heads/main'
58 | steps:
59 | - name: Deploy to GitHub Pages
60 | id: deployment
61 | uses: actions/deploy-pages@v4
--------------------------------------------------------------------------------
/vite.config.ts:
--------------------------------------------------------------------------------
1 | import { defineConfig } from 'vite'
2 | import react from '@vitejs/plugin-react'
3 |
4 | // https://vitejs.dev/config/
5 | export default defineConfig(({ mode }) => ({
6 | plugins: [react()],
7 | define: {
8 | 'process.env.NODE_ENV': JSON.stringify(mode === 'test' ? 'test' : mode),
9 | '__BUILD_DATE__': JSON.stringify(new Date().toISOString()),
10 | '__APP_VERSION__': JSON.stringify(process.env.npm_package_version || '0.2.0'),
11 | },
12 | resolve: {
13 | conditions: mode === 'test' ? ['development'] : [],
14 | },
15 | base: '/klv-extractor/',
16 | build: {
17 | outDir: 'build',
18 | },
19 | server: {
20 | port: 3000,
21 | open: true,
22 | },
23 | test: {
24 | globals: true,
25 | environment: 'jsdom',
26 | setupFiles: './src/setupTests.ts',
27 | testTimeout: 10000,
28 | environmentOptions: {
29 | jsdom: {
30 | resources: 'usable',
31 | },
32 | },
33 | coverage: {
34 | provider: 'v8',
35 | alias: {
36 | '@/': new URL('./src/', import.meta.url).pathname,
37 | },
38 | reporter: ['text', 'text-summary', 'lcov', 'json-summary', 'html', 'cobertura'],
39 | thresholds: {
40 | branches: 60,
41 | functions: 60,
42 | lines: 60,
43 | statements: 60,
44 | },
45 | exclude: [
46 | 'node_modules/',
47 | 'src/setupTests.ts',
48 | 'src/reportWebVitals.ts',
49 | '**/*.test.{ts,tsx}',
50 | '**/*.config.{ts,js}',
51 | 'src/tests/**'
52 | ]
53 | }
54 | }
55 | }))
56 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "klv-extractor",
3 | "version": "0.2.0",
4 | "private": true,
5 | "homepage": "https://ohmrefresh.github.io/klv-extractor",
6 | "dependencies": {
7 | "lucide-react": "^0.561.0",
8 | "react": "^19.2.3",
9 | "react-dom": "^19.2.3",
10 | "web-vitals": "^5.1.0"
11 | },
12 | "devDependencies": {
13 | "@testing-library/dom": "^10.4.1",
14 | "@testing-library/jest-dom": "^6.9.1",
15 | "@testing-library/react": "^16.3.0",
16 | "@testing-library/user-event": "^14.6.1",
17 | "@types/node": "^25.0.1",
18 | "@types/react": "^19.2.7",
19 | "@types/react-dom": "^19.2.3",
20 | "@vitejs/plugin-react": "^5.1.2",
21 | "@vitest/coverage-v8": "^4.0.15",
22 | "@vitest/ui": "^4.0.15",
23 | "gh-pages": "^6.3.0",
24 | "jsdom": "^27.3.0",
25 | "tailwindcss": "^4.1.18",
26 | "typescript": "^5.9.3",
27 | "vite": "^7.2.7",
28 | "vitest": "^4.0.15"
29 | },
30 | "scripts": {
31 | "start": "vite",
32 | "dev": "vite",
33 | "build": "tsc && vite build",
34 | "preview": "vite preview",
35 | "test": "vitest",
36 | "test:coverage": "vitest run --coverage",
37 | "test:ci": "vitest run --coverage",
38 | "test:ui": "vitest --ui",
39 | "predeploy": "npm run build",
40 | "deploy": "gh-pages -d build"
41 | },
42 | "browserslist": {
43 | "production": [
44 | ">0.2%",
45 | "not dead",
46 | "not op_mini all"
47 | ],
48 | "development": [
49 | "last 1 chrome version",
50 | "last 1 firefox version",
51 | "last 1 safari version"
52 | ]
53 | }
54 | }
55 |
--------------------------------------------------------------------------------
/src/components/FileUpload.tsx:
--------------------------------------------------------------------------------
1 | import React, { useRef } from 'react';
2 | import { Upload } from 'lucide-react';
3 |
4 | interface FileUploadProps {
5 | onFileLoad: (content: string, filename: string) => void;
6 | }
7 |
8 | const FileUpload: React.FC = ({ onFileLoad }) => {
9 | const fileRef = useRef(null);
10 |
11 | const handleFileChange = async (e: React.ChangeEvent) => {
12 | const file = e.target.files?.[0];
13 | if (!file) return;
14 |
15 | try {
16 | const text = await file.text();
17 | onFileLoad(text, file.name);
18 | } catch (error) {
19 | console.error('Error reading file:', error);
20 | alert('Error reading file. Please try again.');
21 | }
22 |
23 | e.target.value = '';
24 | };
25 |
26 | return (
27 |
28 |
36 |
37 |
38 | Upload KLV data file (.txt, .log, .csv)
39 |
40 |
46 |
47 | );
48 | };
49 |
50 | export default FileUpload;
--------------------------------------------------------------------------------
/src/components/Statistics.tsx:
--------------------------------------------------------------------------------
1 | import React, { useMemo } from 'react';
2 | import { KLVEntry } from '../utils/KLVParser';
3 |
4 | interface StatisticsProps {
5 | results: KLVEntry[];
6 | }
7 |
8 | interface StatsData {
9 | total: number;
10 | totalValueLength: number;
11 | knownKeys: number;
12 | unknownKeys: number;
13 | keyTypes: Record;
14 | }
15 |
16 | const Statistics: React.FC = ({ results }) => {
17 | const stats: StatsData = useMemo(() => {
18 | const keyTypes: Record = {};
19 | let totalValueLength = 0;
20 | let knownKeys = 0;
21 |
22 | results.forEach(item => {
23 | const category = item.name !== 'Unknown' ? 'Known' : 'Unknown';
24 | keyTypes[category] = (keyTypes[category] || 0) + 1;
25 | totalValueLength += item.len;
26 | if (item.name !== 'Unknown') knownKeys++;
27 | });
28 |
29 | return {
30 | total: results.length,
31 | totalValueLength,
32 | knownKeys,
33 | unknownKeys: results.length - knownKeys,
34 | keyTypes
35 | };
36 | }, [results]);
37 |
38 | if (results.length === 0) {
39 | return null;
40 | }
41 |
42 | return (
43 |
44 |
45 |
{stats.total}
46 |
Total Entries
47 |
48 |
49 |
{stats.knownKeys}
50 |
Known Keys
51 |
52 |
53 |
{stats.unknownKeys}
54 |
Unknown Keys
55 |
56 |
57 |
{stats.totalValueLength}
58 |
Total Length
59 |
60 |
61 | );
62 | };
63 |
64 | export default Statistics;
--------------------------------------------------------------------------------
/src/components/ExportPanel.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import KLVParser, { KLVEntry } from '../utils/KLVParser';
3 |
4 | interface ExportPanelProps {
5 | results: KLVEntry[];
6 | }
7 |
8 | type ExportFormat = 'json' | 'csv' | 'table';
9 |
10 | const ExportPanel: React.FC = ({ results }) => {
11 | const downloadFile = (content: string, filename: string, type: string) => {
12 | const blob = new Blob([content], { type });
13 | const url = URL.createObjectURL(blob);
14 | const a = document.createElement('a');
15 | a.href = url;
16 | a.download = filename;
17 | document.body.appendChild(a);
18 | a.click();
19 | document.body.removeChild(a);
20 | URL.revokeObjectURL(url);
21 | };
22 |
23 | const exportData = (format: ExportFormat) => {
24 | const content = KLVParser.export(results, format);
25 | const timestamp = new Date().toISOString().slice(0, 19).replace(/:/g, '-');
26 | const extensions: Record = { json: 'json', csv: 'csv', table: 'txt' };
27 | const mimeTypes: Record = {
28 | json: 'application/json',
29 | csv: 'text/csv',
30 | table: 'text/plain'
31 | };
32 |
33 | downloadFile(
34 | content,
35 | `klv-data-${timestamp}.${extensions[format]}`,
36 | mimeTypes[format]
37 | );
38 | };
39 |
40 | if (results.length === 0) {
41 | return null;
42 | }
43 |
44 | return (
45 |
46 |
53 |
60 |
67 |
68 | );
69 | };
70 |
71 | export default ExportPanel;
--------------------------------------------------------------------------------
/src/logo.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Logs
2 | logs
3 | *.log
4 | npm-debug.log*
5 | yarn-debug.log*
6 | yarn-error.log*
7 | lerna-debug.log*
8 |
9 | # Diagnostic reports (https://nodejs.org/api/report.html)
10 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
11 |
12 | # Runtime data
13 | pids
14 | *.pid
15 | *.seed
16 | *.pid.lock
17 |
18 | # Directory for instrumented libs generated by jscoverage/JSCover
19 | lib-cov
20 |
21 | # Coverage directory used by tools like istanbul
22 | coverage
23 | *.lcov
24 |
25 | # nyc test coverage
26 | .nyc_output
27 |
28 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
29 | .grunt
30 |
31 | # Bower dependency directory (https://bower.io/)
32 | bower_components
33 |
34 | # node-waf configuration
35 | .lock-wscript
36 |
37 | # Compiled binary addons (https://nodejs.org/api/addons.html)
38 | build/Release
39 |
40 | # Dependency directories
41 | node_modules/
42 | jspm_packages/
43 |
44 | # Snowpack dependency directory (https://snowpack.dev/)
45 | web_modules/
46 |
47 | # TypeScript cache
48 | *.tsbuildinfo
49 |
50 | # Optional npm cache directory
51 | .npm
52 |
53 | # Optional eslint cache
54 | .eslintcache
55 |
56 | # Optional stylelint cache
57 | .stylelintcache
58 |
59 | # Optional REPL history
60 | .node_repl_history
61 |
62 | # Output of 'npm pack'
63 | *.tgz
64 |
65 | # Yarn Integrity file
66 | .yarn-integrity
67 |
68 | # dotenv environment variable files
69 | .env
70 | .env.*
71 | !.env.example
72 |
73 | # parcel-bundler cache (https://parceljs.org/)
74 | .cache
75 | .parcel-cache
76 |
77 | # Next.js build output
78 | .next
79 | out
80 |
81 | # Nuxt.js build / generate output
82 | .nuxt
83 | dist
84 |
85 | # Gatsby files
86 | .cache/
87 | # Comment in the public line in if your project uses Gatsby and not Next.js
88 | # https://nextjs.org/blog/next-9-1#public-directory-support
89 | # public
90 |
91 | # vuepress build output
92 | .vuepress/dist
93 |
94 | # vuepress v2.x temp and cache directory
95 | .temp
96 | .cache
97 |
98 | # Sveltekit cache directory
99 | .svelte-kit/
100 |
101 | # vitepress build output
102 | **/.vitepress/dist
103 |
104 | # vitepress cache directory
105 | **/.vitepress/cache
106 |
107 | # Docusaurus cache and generated files
108 | .docusaurus
109 |
110 | # Serverless directories
111 | .serverless/
112 |
113 | # FuseBox cache
114 | .fusebox/
115 |
116 | # DynamoDB Local files
117 | .dynamodb/
118 |
119 | # Firebase cache directory
120 | .firebase/
121 |
122 | # TernJS port file
123 | .tern-port
124 |
125 | # Stores VSCode versions used for testing VSCode extensions
126 | .vscode-test
127 |
128 | # yarn v3
129 | .pnp.*
130 | .yarn/*
131 | !.yarn/patches
132 | !.yarn/plugins
133 | !.yarn/releases
134 | !.yarn/sdks
135 | !.yarn/versions
136 |
137 | # Vite logs files
138 | vite.config.js.timestamp-*
139 | vite.config.ts.timestamp-*
140 |
141 | build/*
--------------------------------------------------------------------------------
/public/icon.svg:
--------------------------------------------------------------------------------
1 |
57 |
--------------------------------------------------------------------------------
/src/tests/helpers/testUtils.ts:
--------------------------------------------------------------------------------
1 | import { vi } from 'vitest';
2 | import { KLVEntry } from '../../utils/KLVParser';
3 |
4 | // Mock KLV data for testing
5 | export const mockKLVEntries: KLVEntry[] = [
6 | {
7 | key: '002',
8 | len: 6,
9 | value: 'AB48DE',
10 | pos: 0,
11 | name: 'Tracking Number'
12 | },
13 | {
14 | key: '026',
15 | len: 4,
16 | value: '4577',
17 | pos: 11,
18 | name: 'Merchant Category Code'
19 | },
20 | {
21 | key: '042',
22 | len: 15,
23 | value: 'MERCHANT_ID_123',
24 | pos: 20,
25 | name: 'Merchant Identifier'
26 | },
27 | {
28 | key: '999',
29 | len: 4,
30 | value: 'TEST',
31 | pos: 40,
32 | name: 'Generic Key'
33 | }
34 | ];
35 |
36 | export const mockValidKLVString = '00206AB48DE026044577042015MERCHANT_ID_12399904TEST';
37 | export const mockInvalidKLVString = 'INVALID_KLV_DATA';
38 | export const mockEmptyKLVString = '';
39 |
40 | // Mock file content for testing file uploads
41 | export const mockFileContent = {
42 | validKLV: '00206AB48DE026044577\n042015MERCHANT_ID_123',
43 | invalidKLV: 'INVALID_DATA\nMORE_INVALID',
44 | empty: ''
45 | };
46 |
47 | // Mock batch processing data
48 | export const mockBatchData = [
49 | '00206AB48DE026044577',
50 | '042015MERCHANT_ID_123',
51 | 'INVALID_ENTRY',
52 | '99904TEST'
53 | ];
54 |
55 | // Test utilities for component testing
56 | export const createMockFile = (content: string, name: string, type: string = 'text/plain'): File => {
57 | const file = new File([content], name, { type });
58 |
59 | // Mock the text() method for testing
60 | (file as any).text = vi.fn().mockResolvedValue(content);
61 |
62 | return file;
63 | };
64 |
65 | // Mock clipboard API
66 | export const mockClipboardAPI = () => {
67 | const mockWriteText = vi.fn().mockResolvedValue(undefined);
68 |
69 | Object.defineProperty(navigator, 'clipboard', {
70 | value: {
71 | writeText: mockWriteText
72 | },
73 | configurable: true
74 | });
75 |
76 | return mockWriteText;
77 | };
78 |
79 | // Mock URL APIs for file downloads
80 | export const mockURLAPIs = () => {
81 | const mockCreateObjectURL = vi.fn().mockReturnValue('mock-blob-url');
82 | const mockRevokeObjectURL = vi.fn();
83 |
84 | Object.defineProperty(URL, 'createObjectURL', {
85 | value: mockCreateObjectURL,
86 | configurable: true
87 | });
88 |
89 | Object.defineProperty(URL, 'revokeObjectURL', {
90 | value: mockRevokeObjectURL,
91 | configurable: true
92 | });
93 |
94 | return { mockCreateObjectURL, mockRevokeObjectURL };
95 | };
96 |
97 | // Mock DOM methods for file downloads
98 | export const mockDOMFileDownload = () => {
99 | const mockClick = vi.fn();
100 | const mockAppendChild = vi.fn();
101 | const mockRemoveChild = vi.fn();
102 |
103 | const mockElement = {
104 | click: mockClick,
105 | href: '',
106 | download: '',
107 | style: {}
108 | };
109 |
110 | const originalCreateElement = document.createElement;
111 | document.createElement = vi.fn().mockReturnValue(mockElement);
112 |
113 | const originalAppendChild = document.body.appendChild;
114 | document.body.appendChild = mockAppendChild;
115 |
116 | const originalRemoveChild = document.body.removeChild;
117 | document.body.removeChild = mockRemoveChild;
118 |
119 | // Return cleanup function
120 | return {
121 | cleanup: () => {
122 | document.createElement = originalCreateElement;
123 | document.body.appendChild = originalAppendChild;
124 | document.body.removeChild = originalRemoveChild;
125 | },
126 | mocks: {
127 | mockClick,
128 | mockAppendChild,
129 | mockRemoveChild,
130 | mockElement
131 | }
132 | };
133 | };
134 |
135 | // Helper to wait for promises to resolve
136 | export const waitForPromises = () => new Promise(resolve => setTimeout(resolve, 0));
137 |
138 | // Mock console methods
139 | export const mockConsole = () => {
140 | const originalError = console.error;
141 | const originalLog = console.log;
142 | const originalWarn = console.warn;
143 |
144 | console.error = vi.fn();
145 | console.log = vi.fn();
146 | console.warn = vi.fn();
147 |
148 | return {
149 | cleanup: () => {
150 | console.error = originalError;
151 | console.log = originalLog;
152 | console.warn = originalWarn;
153 | },
154 | mocks: {
155 | error: console.error,
156 | log: console.log,
157 | warn: console.warn
158 | }
159 | };
160 | };
161 |
162 | // Assertion helpers
163 | export const expectValidKLVEntry = (entry: KLVEntry, expectedKey: string, expectedValue: string) => {
164 | expect(entry.key).toBe(expectedKey);
165 | expect(entry.value).toBe(expectedValue);
166 | expect(entry.len).toBe(expectedValue.length);
167 | expect(typeof entry.pos).toBe('number');
168 | expect(typeof entry.name).toBe('string');
169 | };
170 |
171 | export const expectValidParseResult = (result: { results: KLVEntry[], errors: string[] }, expectedCount: number) => {
172 | expect(result.errors).toHaveLength(0);
173 | expect(result.results).toHaveLength(expectedCount);
174 | result.results.forEach(entry => {
175 | expect(entry).toHaveProperty('key');
176 | expect(entry).toHaveProperty('len');
177 | expect(entry).toHaveProperty('value');
178 | expect(entry).toHaveProperty('pos');
179 | expect(entry).toHaveProperty('name');
180 | });
181 | };
--------------------------------------------------------------------------------
/src/tests/components/FileUpload.test.tsx:
--------------------------------------------------------------------------------
1 | import { render, screen, fireEvent, waitFor } from '@testing-library/react';
2 | import userEvent from '@testing-library/user-event';
3 | import { vi } from 'vitest';
4 | import FileUpload from '../../components/FileUpload';
5 |
6 | // Mock file reading
7 | const mockFileContent = '00206AB48DE026044577';
8 | const mockFileName = 'test-klv-data.txt';
9 |
10 | describe('FileUpload', () => {
11 | const mockOnFileLoad = vi.fn();
12 |
13 | beforeEach(() => {
14 | mockOnFileLoad.mockClear();
15 | });
16 |
17 | it('should render file upload component', () => {
18 | render();
19 |
20 | expect(screen.getByText('Upload KLV data file (.txt, .log, .csv)')).toBeInTheDocument();
21 | expect(screen.getByText('Choose File')).toBeInTheDocument();
22 | expect(screen.getByLabelText('Upload KLV data file')).toBeInTheDocument();
23 | });
24 |
25 | it('should have correct file input attributes', () => {
26 | render();
27 |
28 | const fileInput = screen.getByLabelText('Upload KLV data file');
29 | expect(fileInput).toHaveAttribute('type', 'file');
30 | expect(fileInput).toHaveAttribute('accept', '.txt,.log,.csv,.json');
31 | expect(fileInput).toHaveClass('hidden');
32 | });
33 |
34 | it('should trigger file input when choose file button is clicked', async () => {
35 | const user = userEvent.setup();
36 | render();
37 |
38 | const chooseFileButton = screen.getByText('Choose File');
39 | const fileInput = screen.getByLabelText('Upload KLV data file');
40 |
41 | const clickSpy = vi.spyOn(fileInput, 'click');
42 |
43 | await user.click(chooseFileButton);
44 |
45 | expect(clickSpy).toHaveBeenCalled();
46 |
47 | clickSpy.mockRestore();
48 | });
49 |
50 | it('should handle file selection and call onFileLoad', async () => {
51 | render();
52 |
53 | const fileInput = screen.getByLabelText('Upload KLV data file');
54 |
55 | // Create a mock file with text() method
56 | const mockFile = new File([mockFileContent], mockFileName, { type: 'text/plain' });
57 |
58 | // Mock the text() method on the File prototype
59 | const originalText = File.prototype.text;
60 | File.prototype.text = vi.fn().mockResolvedValue(mockFileContent);
61 |
62 | fireEvent.change(fileInput, { target: { files: [mockFile] } });
63 |
64 | await waitFor(() => {
65 | expect(mockOnFileLoad).toHaveBeenCalledWith(mockFileContent, mockFileName);
66 | });
67 |
68 | // Restore original method
69 | File.prototype.text = originalText;
70 | });
71 |
72 | it('should clear input value after file processing', async () => {
73 | render();
74 |
75 | const fileInput = screen.getByLabelText('Upload KLV data file') as HTMLInputElement;
76 | const mockFile = new File([mockFileContent], mockFileName, { type: 'text/plain' });
77 |
78 | // Mock the text() method
79 | File.prototype.text = vi.fn().mockResolvedValue(mockFileContent);
80 |
81 | fireEvent.change(fileInput, { target: { files: [mockFile] } });
82 |
83 | await waitFor(() => {
84 | expect(mockOnFileLoad).toHaveBeenCalled();
85 | });
86 |
87 | expect(fileInput.value).toBe('');
88 | });
89 |
90 | it('should handle file reading errors gracefully', async () => {
91 | const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
92 | const alertSpy = vi.spyOn(window, 'alert').mockImplementation(() => {});
93 |
94 | render();
95 |
96 | const fileInput = screen.getByLabelText('Upload KLV data file');
97 | const mockFile = new File([mockFileContent], mockFileName, { type: 'text/plain' });
98 |
99 | // Mock text() to throw an error
100 | File.prototype.text = vi.fn().mockRejectedValue(new Error('File read error'));
101 |
102 | fireEvent.change(fileInput, { target: { files: [mockFile] } });
103 |
104 | await waitFor(() => {
105 | expect(consoleSpy).toHaveBeenCalledWith('Error reading file:', expect.any(Error));
106 | expect(alertSpy).toHaveBeenCalledWith('Error reading file. Please try again.');
107 | });
108 |
109 | expect(mockOnFileLoad).not.toHaveBeenCalled();
110 |
111 | consoleSpy.mockRestore();
112 | alertSpy.mockRestore();
113 | });
114 |
115 | it('should not process when no file is selected', async () => {
116 | render();
117 |
118 | const fileInput = screen.getByLabelText('Upload KLV data file');
119 |
120 | fireEvent.change(fileInput, { target: { files: [] } });
121 |
122 | expect(mockOnFileLoad).not.toHaveBeenCalled();
123 | });
124 |
125 | it('should have proper styling classes', () => {
126 | render();
127 |
128 | const uploadArea = screen.getByText('Upload KLV data file (.txt, .log, .csv)').closest('div');
129 | expect(uploadArea).toHaveClass('border-2', 'border-dashed', 'border-gray-300', 'rounded', 'p-4');
130 |
131 | const button = screen.getByText('Choose File');
132 | expect(button).toHaveClass('px-4', 'py-2', 'bg-blue-500', 'text-white', 'rounded');
133 | });
134 | });
--------------------------------------------------------------------------------
/CLAUDE.md:
--------------------------------------------------------------------------------
1 | # CLAUDE.md
2 |
3 | This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
4 |
5 | ## Project Overview
6 |
7 | This is a React-based KLV (Key-Length-Value) data extraction and processing suite for Paymentology transaction data. The application provides a complete toolkit for parsing, building, and batch processing KLV formatted data.
8 |
9 | ## Development Commands
10 |
11 | - `npm start` - Runs the app in development mode with Vite (http://localhost:3000 or next available port)
12 | - `npm run dev` - Alternative command to start development server
13 | - `npm run build` - Builds the app for production to the `build` folder using TypeScript and Vite
14 | - `npm run preview` - Preview the production build locally
15 | - `npm test` - Launches Vitest test runner in interactive watch mode
16 | - `npm run test:coverage` - Runs all tests with coverage report
17 | - `npm run test:ci` - Runs tests in CI mode with coverage (no watch)
18 | - `npm run test:ui` - Opens Vitest UI for interactive test running
19 |
20 | ## Core Architecture
21 |
22 | ### KLV Parser Engine (`src/utils/KLVParser.ts`)
23 | The heart of the application is the KLVParser utility which:
24 | - Contains complete KLV definitions for 100+ Paymentology fields (keys 002-999)
25 | - Parses KLV strings with format validation and error handling
26 | - Exports data to JSON, CSV, and table formats
27 | - Builds KLV strings from component entries
28 | - Validates KLV data structure and integrity
29 |
30 | ### Component Architecture
31 | The application uses a tab-based interface with four main sections:
32 |
33 | **Main App (`src/App.jsx`)**
34 | - Manages application state (activeTab, klvInput, searchTerm, history)
35 | - Handles file uploads and history management
36 | - Coordinates between different processing modes
37 |
38 | **Core Components:**
39 | - `FileUpload` - Handles file input for KLV data
40 | - `ExportPanel` - Provides export functionality to various formats
41 | - `Statistics` - Shows parsing statistics and data insights
42 | - `KLVBuilder` - Interactive form for constructing KLV strings
43 | - `BatchProcessor` - Processes multiple KLV entries simultaneously
44 |
45 | ### Data Flow
46 | 1. Input: KLV strings via manual input, file upload, or builder
47 | 2. Processing: KLVParser validates and parses data into structured format
48 | 3. Display: Results shown with search/filter capabilities
49 | 4. Export: Data can be exported to multiple formats
50 | 5. History: Successful parses saved to processing history
51 |
52 | ### Key Features
53 | - **Real-time parsing** with immediate error feedback
54 | - **Search and filter** across parsed results
55 | - **Batch processing** for multiple KLV entries
56 | - **Interactive builder** for creating KLV strings
57 | - **Export capabilities** (JSON, CSV, table format)
58 | - **Processing history** with load/copy functionality
59 | - **Sample data** for testing and demonstration
60 |
61 | ## Technology Stack
62 |
63 | - **React 18** with functional components and hooks
64 | - **TypeScript** for type safety and better development experience
65 | - **Vite** - Modern build tool and development server
66 | - **Vitest** - Fast unit test framework with Jest-compatible API
67 | - **Tailwind CSS** for styling (included as dev dependency)
68 | - **Lucide React** for icons
69 |
70 | ## KLV Data Format
71 |
72 | The application processes Key-Length-Value data with the structure:
73 | - 3-digit key (002-999)
74 | - 2-digit length field (hexadecimal)
75 | - Variable-length value field
76 | - Example: `00206AB48DE` = Key=002, Length=06, Value=AB48DE
77 |
78 | ## Development Notes
79 |
80 | - Components follow React functional patterns with hooks and TypeScript interfaces
81 | - State management is handled at the App level and passed down via typed props
82 | - Error handling is built into the parsing engine with proper type safety
83 | - The application uses TypeScript for compile-time type checking and better IDE support
84 | - All interfaces and types are properly defined for KLV data structures
85 | - The application is designed for defensive security (data parsing and analysis only)
86 | - No server-side dependencies - pure client-side processing
87 |
88 | ## TypeScript Interfaces
89 |
90 | Key interfaces defined in the codebase:
91 | - `KLVEntry` - Represents a parsed KLV entry with key, length, value, position, and name
92 | - `KLVParseResult` - Contains parsing results and any errors
93 | - `KLVValidationResult` - Validation results for KLV data
94 | - `KLVBuildEntry` - Entry format for building KLV strings
95 |
96 | ## Testing
97 |
98 | The application includes comprehensive unit and integration tests:
99 |
100 | ### Test Structure
101 | - **Unit tests** for KLV Parser utility (`src/tests/utils/KLVParser.test.ts`)
102 | - **Component tests** for React components (`src/tests/components/*.test.tsx`)
103 | - **Integration tests** for full application workflows (`src/tests/integration/*.test.tsx`)
104 | - **Test utilities** for mocking and shared test helpers (`src/tests/helpers/testUtils.ts`)
105 |
106 | ### Test Coverage
107 | Tests cover:
108 | - KLV parsing with valid and invalid data
109 | - Error handling and edge cases
110 | - Component rendering and user interactions
111 | - File upload functionality
112 | - Export operations
113 | - Search and filtering
114 | - Tab navigation and state management
115 | - Clipboard operations
116 | - History management
117 |
118 | ### Running Tests
119 | ```bash
120 | npm test # Interactive test runner
121 | npm run test:coverage # Tests with coverage report
122 | npm run test:ci # CI-friendly test run
123 | ```
124 |
125 | ### Test Dependencies
126 | - Vitest (test framework with Jest-compatible API)
127 | - React Testing Library (component testing)
128 | - @testing-library/jest-dom (DOM assertions)
129 | - @testing-library/user-event (user interaction simulation)
130 | - @vitest/coverage-v8 (code coverage)
--------------------------------------------------------------------------------
/src/components/BatchProcessor.tsx:
--------------------------------------------------------------------------------
1 | import React, { useState } from 'react';
2 | import { CheckCircle, AlertCircle, FileText } from 'lucide-react';
3 | import KLVParser, { KLVParseResult } from '../utils/KLVParser';
4 |
5 | interface BatchResult extends KLVParseResult {
6 | line: number;
7 | input: string;
8 | }
9 |
10 | interface BatchProcessorProps {
11 | onProcess: (results: BatchResult[]) => void;
12 | }
13 |
14 | const BatchProcessor: React.FC = ({ onProcess }) => {
15 | const [batchInput, setBatchInput] = useState('');
16 | const [processing, setProcessing] = useState(false);
17 | const [results, setResults] = useState([]);
18 |
19 | const processBatch = async () => {
20 | setProcessing(true);
21 | const lines = batchInput.split('\n').filter(line => line.trim());
22 |
23 | const batchResults: BatchResult[] = lines.map((line, index) => ({
24 | line: index + 1,
25 | input: line.trim(),
26 | ...KLVParser.parse(line.trim())
27 | }));
28 |
29 | // Simulate processing delay for better UX
30 | await new Promise(resolve => setTimeout(resolve, 500));
31 |
32 | setResults(batchResults);
33 | setProcessing(false);
34 | onProcess(batchResults);
35 | };
36 |
37 | const loadSampleBatch = () => {
38 | const sampleData = [
39 | '00206AB48DE026044577',
40 | '04210000050010008USD04305Test Merchant',
41 | '25103EMV25107Visa26105542200015INVALID_ENTRY',
42 | '04210050026055422600512345678042036MERCHANT_ID_12343015Test Transaction'
43 | ].join('\n');
44 | setBatchInput(sampleData);
45 | };
46 |
47 | const clearBatch = () => {
48 | setBatchInput('');
49 | setResults([]);
50 | };
51 |
52 | return (
53 |
54 |
55 |
56 |
57 | Batch Processor
58 |
59 |
60 |
66 |
72 |
73 |
74 |
75 |
76 |
79 |
90 |
91 |
105 |
106 | {results.length > 0 && (
107 |
108 |
109 |
Batch Results
110 |
111 | {results.filter(r => r.errors.length === 0).length} successful, {' '}
112 | {results.filter(r => r.errors.length > 0).length} failed
113 |
114 |
115 |
116 |
117 | {results.map((result, i) => (
118 |
121 |
122 |
Line {result.line}
123 |
124 | {result.errors.length === 0 ? (
125 |
126 |
127 | {result.results.length} entries
128 |
129 | ) : (
130 |
131 |
132 | {result.errors.length} errors
133 |
134 | )}
135 |
136 |
137 |
138 |
139 | {result.input}
140 |
141 |
142 | {result.errors.length > 0 && (
143 |
144 | Errors: {result.errors.join(', ')}
145 |
146 | )}
147 |
148 | {result.results.length > 0 && (
149 |
150 | Keys found: {result.results.map(r => r.key).join(', ')}
151 |
152 | )}
153 |
154 | ))}
155 |
156 |
157 | )}
158 |
159 | );
160 | };
161 |
162 | export default BatchProcessor;
--------------------------------------------------------------------------------
/.github/workflows/ci.yml:
--------------------------------------------------------------------------------
1 | name: CI - Test & Coverage
2 |
3 | on:
4 | pull_request:
5 | branches: [ main, develop ]
6 |
7 | permissions:
8 | contents: read
9 | pull-requests: write
10 | checks: write
11 |
12 | jobs:
13 | test:
14 | name: Test & Coverage
15 | runs-on: ubuntu-latest
16 |
17 | steps:
18 | - name: Checkout code
19 | uses: actions/checkout@v4
20 |
21 | - name: Setup Node.js
22 | uses: actions/setup-node@v4
23 | with:
24 | node-version: '20'
25 | cache: 'npm'
26 |
27 | - name: Cache dependencies
28 | uses: actions/cache@v4
29 | with:
30 | path: ~/.npm
31 | key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }}
32 | restore-keys: |
33 | ${{ runner.os }}-node-
34 |
35 | - name: Install dependencies
36 | run: npm ci
37 |
38 | - name: Run linter
39 | run: npm run lint --if-present || echo "No linter configured"
40 | continue-on-error: true
41 |
42 | - name: Run tests with coverage
43 | run: npm run test:coverage
44 |
45 | - name: Generate Coverage Report
46 | uses: irongut/CodeCoverageSummary@v1.3.0
47 | with:
48 | filename: coverage/cobertura-coverage.xml
49 | badge: true
50 | fail_below_min: false
51 | format: markdown
52 | hide_branch_rate: false
53 | hide_complexity: true
54 | indicators: true
55 | output: both
56 | thresholds: '60 80'
57 |
58 | - name: Create Coverage Summary
59 | if: always()
60 | run: |
61 | echo "## 📊 Test Coverage Report" > coverage-summary.md
62 | echo "" >> coverage-summary.md
63 | echo "### Overall Coverage" >> coverage-summary.md
64 | echo "" >> coverage-summary.md
65 |
66 | if [ -f coverage/coverage-summary.json ]; then
67 | node -e "
68 | const coverage = require('./coverage/coverage-summary.json');
69 | const total = coverage.total;
70 |
71 | const getEmoji = (pct) => {
72 | if (pct >= 90) return '🟢';
73 | if (pct >= 80) return '🟡';
74 | if (pct >= 60) return '🟠';
75 | return '🔴';
76 | };
77 |
78 | const getStatus = (pct) => {
79 | if (pct >= 80) return '✅ Excellent';
80 | if (pct >= 60) return '⚠️ Good';
81 | return '❌ Needs Improvement';
82 | };
83 |
84 | console.log('| Metric | Coverage | Status |');
85 | console.log('|--------|----------|--------|');
86 | console.log('| Statements | ' + getEmoji(total.statements.pct) + ' **' + total.statements.pct + '%** | ' + getStatus(total.statements.pct) + ' |');
87 | console.log('| Branches | ' + getEmoji(total.branches.pct) + ' **' + total.branches.pct + '%** | ' + getStatus(total.branches.pct) + ' |');
88 | console.log('| Functions | ' + getEmoji(total.functions.pct) + ' **' + total.functions.pct + '%** | ' + getStatus(total.functions.pct) + ' |');
89 | console.log('| Lines | ' + getEmoji(total.lines.pct) + ' **' + total.lines.pct + '%** | ' + getStatus(total.lines.pct) + ' |');
90 | console.log('');
91 | console.log('### 📈 Coverage Trend');
92 | console.log('');
93 | console.log('Current overall coverage: **' + total.lines.pct + '%**');
94 | " >> coverage-summary.md
95 |
96 | echo "" >> coverage-summary.md
97 | echo "### 📁 Files with Low Coverage" >> coverage-summary.md
98 | echo "" >> coverage-summary.md
99 |
100 | node -e "
101 | const coverage = require('./coverage/coverage-summary.json');
102 | const files = Object.entries(coverage)
103 | .filter(([key]) => key !== 'total')
104 | .map(([path, data]) => ({
105 | path: path.replace(/^.*\/src\//, 'src/'),
106 | lines: data.lines.pct,
107 | statements: data.statements.pct,
108 | branches: data.branches.pct,
109 | functions: data.functions.pct
110 | }))
111 | .filter(file => file.lines < 80)
112 | .sort((a, b) => a.lines - b.lines)
113 | .slice(0, 10);
114 |
115 | if (files.length > 0) {
116 | console.log('| File | Lines | Branches | Functions |');
117 | console.log('|------|-------|----------|-----------|');
118 | files.forEach(file => {
119 | console.log('| \`' + file.path + '\` | ' + file.lines + '% | ' + file.branches + '% | ' + file.functions + '% |');
120 | });
121 | } else {
122 | console.log('🎉 All files have coverage above 80%!');
123 | }
124 | " >> coverage-summary.md
125 |
126 | echo "" >> coverage-summary.md
127 | echo "### 🎯 Coverage Thresholds" >> coverage-summary.md
128 | echo "" >> coverage-summary.md
129 | echo "- 🟢 Excellent: ≥ 90%" >> coverage-summary.md
130 | echo "- 🟡 Good: 80-89%" >> coverage-summary.md
131 | echo "- 🟠 Acceptable: 60-79%" >> coverage-summary.md
132 | echo "- 🔴 Needs Improvement: < 60%" >> coverage-summary.md
133 | fi
134 |
135 | - name: Add Coverage PR Comment
136 | uses: marocchino/sticky-pull-request-comment@v2
137 | if: github.event_name == 'pull_request'
138 | with:
139 | recreate: true
140 | path: coverage-summary.md
141 |
142 | - name: Write to Job Summary
143 | if: always()
144 | run: cat coverage-summary.md >> $GITHUB_STEP_SUMMARY
145 |
146 | - name: Upload coverage to Codecov
147 | uses: codecov/codecov-action@v4
148 | with:
149 | token: ${{ secrets.CODECOV_TOKEN }}
150 | files: ./coverage/lcov.info
151 | flags: unittests
152 | name: codecov-umbrella
153 | fail_ci_if_error: false
154 | verbose: true
155 | continue-on-error: true
156 |
157 | - name: Archive coverage reports
158 | uses: actions/upload-artifact@v4
159 | with:
160 | name: coverage-report-${{ github.sha }}
161 | path: |
162 | coverage/
163 | coverage-summary.md
164 | retention-days: 30
165 |
166 | - name: Check coverage threshold
167 | run: |
168 | COVERAGE=$(node -e "const c = require('./coverage/coverage-summary.json'); console.log(c.total.lines.pct)")
169 | echo "Current coverage: $COVERAGE%"
170 | if (( $(echo "$COVERAGE < 60" | bc -l) )); then
171 | echo "❌ Coverage is below 60% threshold"
172 | exit 1
173 | else
174 | echo "✅ Coverage meets minimum threshold"
175 | fi
176 | continue-on-error: true
--------------------------------------------------------------------------------
/src/tests/components/Statistics.test.tsx:
--------------------------------------------------------------------------------
1 | import { render, screen } from '@testing-library/react';
2 | import Statistics from '../../components/Statistics';
3 | import { KLVEntry } from '../../utils/KLVParser';
4 |
5 | describe('Statistics', () => {
6 | const mockResults: KLVEntry[] = [
7 | {
8 | key: '002',
9 | len: 6,
10 | value: 'AB48DE',
11 | pos: 0,
12 | name: 'Tracking Number'
13 | },
14 | {
15 | key: '026',
16 | len: 4,
17 | value: '4577',
18 | pos: 11,
19 | name: 'Merchant Category Code'
20 | },
21 | {
22 | key: '999',
23 | len: 5,
24 | value: 'TEST1',
25 | pos: 20,
26 | name: 'Unknown'
27 | }
28 | ];
29 |
30 | it('should render statistics for provided results', () => {
31 | render();
32 |
33 | expect(screen.getByText('3')).toBeInTheDocument(); // Total entries
34 | expect(screen.getByText('Total Entries')).toBeInTheDocument();
35 |
36 | expect(screen.getByText('2')).toBeInTheDocument(); // Known keys
37 | expect(screen.getByText('Known Keys')).toBeInTheDocument();
38 |
39 | expect(screen.getByText('1')).toBeInTheDocument(); // Unknown keys
40 | expect(screen.getByText('Unknown Keys')).toBeInTheDocument();
41 |
42 | expect(screen.getByText('15')).toBeInTheDocument(); // Total length (6+4+5)
43 | expect(screen.getByText('Total Length')).toBeInTheDocument();
44 | });
45 |
46 | it('should calculate statistics correctly for all known keys', () => {
47 | const knownResults: KLVEntry[] = [
48 | {
49 | key: '002',
50 | len: 3,
51 | value: 'ABC',
52 | pos: 0,
53 | name: 'Tracking Number'
54 | },
55 | {
56 | key: '026',
57 | len: 2,
58 | value: 'XY',
59 | pos: 8,
60 | name: 'Merchant Category Code'
61 | }
62 | ];
63 |
64 | render();
65 |
66 | // Use more specific selectors
67 | const statsContainer = screen.getByText('Total Entries').parentElement;
68 | expect(statsContainer).toHaveTextContent('2');
69 |
70 | const knownKeysContainer = screen.getByText('Known Keys').parentElement;
71 | expect(knownKeysContainer).toHaveTextContent('2');
72 |
73 | const unknownKeysContainer = screen.getByText('Unknown Keys').parentElement;
74 | expect(unknownKeysContainer).toHaveTextContent('0');
75 |
76 | const lengthContainer = screen.getByText('Total Length').parentElement;
77 | expect(lengthContainer).toHaveTextContent('5');
78 | });
79 |
80 | it('should calculate statistics correctly for all unknown keys', () => {
81 | const unknownResults: KLVEntry[] = [
82 | {
83 | key: '800',
84 | len: 4,
85 | value: 'TEST',
86 | pos: 0,
87 | name: 'Unknown'
88 | },
89 | {
90 | key: '801',
91 | len: 3,
92 | value: 'XYZ',
93 | pos: 9,
94 | name: 'Unknown'
95 | }
96 | ];
97 |
98 | render();
99 |
100 | const statsContainer = screen.getByText('Total Entries').parentElement;
101 | expect(statsContainer).toHaveTextContent('2');
102 |
103 | const knownKeysContainer = screen.getByText('Known Keys').parentElement;
104 | expect(knownKeysContainer).toHaveTextContent('0');
105 |
106 | const unknownKeysContainer = screen.getByText('Unknown Keys').parentElement;
107 | expect(unknownKeysContainer).toHaveTextContent('2');
108 |
109 | const lengthContainer = screen.getByText('Total Length').parentElement;
110 | expect(lengthContainer).toHaveTextContent('7');
111 | });
112 |
113 | it('should handle empty results array', () => {
114 | const { container } = render();
115 |
116 | // Component should not render when results are empty
117 | expect(container.firstChild).toBeNull();
118 | });
119 |
120 | it('should handle zero-length values correctly', () => {
121 | const zeroLengthResults: KLVEntry[] = [
122 | {
123 | key: '002',
124 | len: 0,
125 | value: '',
126 | pos: 0,
127 | name: 'Tracking Number'
128 | },
129 | {
130 | key: '026',
131 | len: 3,
132 | value: 'ABC',
133 | pos: 5,
134 | name: 'Merchant Category Code'
135 | }
136 | ];
137 |
138 | render();
139 |
140 | const statsContainer = screen.getByText('Total Entries').parentElement;
141 | expect(statsContainer).toHaveTextContent('2');
142 |
143 | const knownKeysContainer = screen.getByText('Known Keys').parentElement;
144 | expect(knownKeysContainer).toHaveTextContent('2');
145 |
146 | const unknownKeysContainer = screen.getByText('Unknown Keys').parentElement;
147 | expect(unknownKeysContainer).toHaveTextContent('0');
148 |
149 | const lengthContainer = screen.getByText('Total Length').parentElement;
150 | expect(lengthContainer).toHaveTextContent('3');
151 | });
152 |
153 | it('should have correct styling classes', () => {
154 | render();
155 |
156 | const container = screen.getByText('Total Entries').closest('div')?.parentElement?.parentElement;
157 | expect(container).toHaveClass('grid', 'grid-cols-2', 'md:grid-cols-4', 'gap-4', 'p-4', 'bg-gray-50', 'rounded-lg');
158 | });
159 |
160 | it('should display correct colors for different statistics', () => {
161 | render();
162 |
163 | // Check text color classes for statistics
164 | const totalElement = screen.getByText('3');
165 | const knownElement = screen.getByText('2');
166 | const unknownElement = screen.getByText('1');
167 | const lengthElement = screen.getByText('15');
168 |
169 | expect(totalElement).toHaveClass('text-blue-600');
170 | expect(knownElement).toHaveClass('text-green-600');
171 | expect(unknownElement).toHaveClass('text-yellow-600');
172 | expect(lengthElement).toHaveClass('text-purple-600');
173 | });
174 |
175 | it('should recalculate statistics when results change', () => {
176 | const initialResults: KLVEntry[] = [
177 | {
178 | key: '002',
179 | len: 3,
180 | value: 'ABC',
181 | pos: 0,
182 | name: 'Tracking Number'
183 | }
184 | ];
185 |
186 | const { rerender } = render();
187 |
188 | const statsContainer = screen.getByText('Total Entries').parentElement;
189 | expect(statsContainer).toHaveTextContent('1');
190 |
191 | const updatedResults: KLVEntry[] = [
192 | ...initialResults,
193 | {
194 | key: '026',
195 | len: 2,
196 | value: 'XY',
197 | pos: 8,
198 | name: 'Merchant Category Code'
199 | }
200 | ];
201 |
202 | rerender();
203 |
204 | const updatedStatsContainer = screen.getByText('Total Entries').parentElement;
205 | expect(updatedStatsContainer).toHaveTextContent('2');
206 | });
207 | });
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # KLV Extractor
2 |
3 | [](https://github.com/ohmrefresh/klv-extractor/actions/workflows/deploy.yml)
4 | [](https://ohmrefresh.github.io/klv-extractor)
5 | [](https://github.com/ohmrefresh/klv-extractor)
6 |
7 | A React-based KLV (Key-Length-Value) data extraction and processing suite for Paymentology transaction data. This application provides a complete toolkit for parsing, building, and batch processing KLV formatted data with comprehensive field definitions and export capabilities.
8 |
9 | ## Features
10 |
11 | - **Real-time KLV parsing** with immediate validation and error feedback
12 | - **Interactive KLV builder** for constructing KLV strings from individual fields
13 | - **Batch processing** for multiple KLV entries simultaneously
14 | - **Search and filter** capabilities across parsed results
15 | - **Export functionality** to JSON, CSV, and table formats
16 | - **Processing history** with load and copy functionality
17 | - **Sample data** included for testing and demonstration
18 | - **Complete field definitions** for 100+ KLV fields (keys 002-999)
19 | - **Version and build information** displayed in UI for tracking deployments
20 |
21 | ## Getting Started
22 |
23 | ### Prerequisites
24 |
25 | - Node.js (version 16 or higher)
26 | - npm or yarn
27 |
28 | ### Installation
29 |
30 | 1. Clone the repository:
31 | ```bash
32 | git clone https://github.com/ohmrefresh/klv-extractor.git
33 | cd klv-extractor
34 | ```
35 |
36 | 2. Install dependencies:
37 | ```bash
38 | npm install
39 | ```
40 |
41 | 3. Start the development server:
42 | ```bash
43 | npm start
44 | ```
45 |
46 | The application will open in your browser at [http://localhost:3000](http://localhost:3000).
47 |
48 | ### Live Demo
49 |
50 | You can try the application without installing it locally at: [https://ohmrefresh.github.io/klv-extractor](https://ohmrefresh.github.io/klv-extractor)
51 |
52 | ## Available Scripts
53 |
54 | ### Development
55 | - `npm start` - Runs the app in development mode with Vite
56 | - `npm run dev` - Alternative command to start the development server
57 | - `npm run build` - Builds the app for production to the `build` folder
58 | - `npm run preview` - Preview the production build locally
59 |
60 | ### Testing
61 | - `npm test` - Launches the test runner in interactive watch mode with Vitest
62 | - `npm run test:coverage` - Runs all tests with coverage report
63 | - `npm run test:ci` - Runs tests in CI mode with coverage (no watch)
64 | - `npm run test:ui` - Opens Vitest UI for interactive test running
65 |
66 | ### Deployment
67 | - `npm run deploy` - Deploy to GitHub Pages
68 | - `npm run predeploy` - Pre-deployment build step
69 |
70 | ## How to Use
71 |
72 | ### 1. KLV Parser Tab
73 | - Enter KLV data manually or upload files containing KLV strings
74 | - View parsed results with field names, values, and positions
75 | - Search and filter through parsed entries
76 | - Export results in multiple formats
77 |
78 | ### 2. KLV Builder Tab
79 | - Interactively build KLV strings using a form interface
80 | - Select from predefined Paymentology fields
81 | - Real-time validation and preview of constructed KLV data
82 | - Copy or export built KLV strings
83 |
84 | ### 3. Batch Processor Tab
85 | - Process multiple KLV entries at once
86 | - Upload files with multiple KLV strings
87 | - View batch processing statistics and results
88 | - Export batch results for further analysis
89 |
90 | ### 4. History Tab
91 | - Review processing history
92 | - Load previous KLV data for re-analysis
93 | - Copy and manage historical entries
94 |
95 | ## KLV Data Format
96 |
97 | The application processes Key-Length-Value data with the structure:
98 | - **3-digit key** (002-999): Identifies the data field
99 | - **2-digit length** (hexadecimal): Specifies value length in bytes
100 | - **Variable-length value**: The actual data content
101 |
102 | **Example:** `00206AB48DE`
103 | - Key: `002`
104 | - Length: `06` (6 bytes)
105 | - Value: `AB48DE`
106 |
107 | ## Technology Stack
108 |
109 | - **React 18** with TypeScript for type safety and modern React features
110 | - **Vite** - Modern build tool and development server
111 | - **Vitest** - Fast unit test framework with Jest-compatible API
112 | - **Tailwind CSS** for utility-first styling and responsive design
113 | - **Lucide React** for consistent and modern iconography
114 | - **React Testing Library** for comprehensive component testing
115 | - **GitHub Pages** for automated deployment and hosting
116 |
117 | ## Architecture
118 |
119 | ### Core Components
120 | - **KLVParser** (`src/utils/KLVParser.ts`) - Core parsing engine with complete field definitions
121 | - **App** (`src/App.tsx`) - Main application state management and tab routing
122 | - **FileUpload** - File handling for KLV data input
123 | - **ExportPanel** - Multi-format export functionality (JSON, CSV, Table)
124 | - **KLVBuilder** - Interactive KLV construction interface
125 | - **BatchProcessor** - Bulk processing capabilities
126 | - **Statistics** - Parsing statistics and data insights display
127 |
128 | ### Data Flow
129 | 1. **Input**: KLV strings via manual input, file upload, or builder
130 | 2. **Processing**: KLVParser validates and parses data into structured format
131 | 3. **Display**: Results shown with search/filter capabilities
132 | 4. **Export**: Data exported to JSON, CSV, or table formats
133 | 5. **History**: Successful parses saved for future reference
134 |
135 | ## Testing
136 |
137 | The application includes comprehensive test coverage using Vitest:
138 |
139 | - **Unit tests** for KLV Parser utility (`src/tests/utils/KLVParser.test.ts`)
140 | - **Component tests** for React components (`src/tests/components/*.test.tsx`)
141 | - **Integration tests** for full application workflows (`src/tests/integration/*.test.tsx`)
142 | - **Test utilities** for mocking and shared helpers (`src/tests/helpers/testUtils.ts`)
143 |
144 | Run tests with coverage:
145 | ```bash
146 | npm run test:coverage
147 | ```
148 |
149 | Interactive test UI:
150 | ```bash
151 | npm run test:ui
152 | ```
153 |
154 | ## Contributing
155 |
156 | 1. Fork the repository
157 | 2. Create a feature branch (`git checkout -b feature/amazing-feature`)
158 | 3. Commit your changes (`git commit -m 'Add amazing feature'`)
159 | 4. Push to the branch (`git push origin feature/amazing-feature`)
160 | 5. Open a Pull Request
161 |
162 | ## Security
163 |
164 | This application is designed for defensive security purposes only:
165 | - Client-side processing with no server dependencies
166 | - Data parsing and analysis functionality
167 | - No malicious code generation or modification capabilities
168 |
169 | ## License
170 |
171 | This project is licensed under the MIT License - see the LICENSE file for details.
172 |
173 | ## Version Information
174 |
175 | The application displays version and build information in the UI footer:
176 | - **Version number** - Automatically read from `package.json`
177 | - **Build date/time** - Generated during the build process via Vite
178 |
179 | This helps track deployments and ensures users know which version they're using.
180 |
181 | ## Support
182 |
183 | For issues and questions:
184 | - Create an issue in the GitHub repository
185 | - Review the documentation in `CLAUDE.md` for development guidance
--------------------------------------------------------------------------------
/src/components/KLVBuilder.tsx:
--------------------------------------------------------------------------------
1 | import React, { useState } from 'react';
2 | import { Trash2, Plus, AlertCircle, Copy, CheckCircle, Info, Eye } from 'lucide-react';
3 | import KLVParser, { KLVBuildEntry } from '../utils/KLVParser';
4 |
5 | interface KLVBuilderProps {
6 | onBuild: (klvString: string) => void;
7 | }
8 |
9 | const KLVBuilder: React.FC = ({ onBuild }) => {
10 | const [entries, setEntries] = useState([{ key: '002', value: '' }]);
11 | const [copied, setCopied] = useState(false);
12 | const [searchKey, setSearchKey] = useState('');
13 |
14 | const getDuplicateKeys = (): Set => {
15 | const keyCounts = new Map();
16 | entries.forEach(entry => {
17 | keyCounts.set(entry.key, (keyCounts.get(entry.key) || 0) + 1);
18 | });
19 | return new Set(
20 | Array.from(keyCounts.entries())
21 | .filter(([_, count]) => count > 1)
22 | .map(([key, _]) => key)
23 | );
24 | };
25 |
26 | const hasDuplicates = (): boolean => {
27 | return getDuplicateKeys().size > 0;
28 | };
29 |
30 | const getAvailableKeys = (): string[] => {
31 | const usedKeys = new Set(entries.map(e => e.key));
32 | return Object.keys(KLVParser.definitions).filter(key => !usedKeys.has(key));
33 | };
34 |
35 | const addEntry = () => {
36 | const availableKeys = getAvailableKeys();
37 | const nextKey = availableKeys.length > 0 ? availableKeys[0] : '002';
38 | setEntries([...entries, { key: nextKey, value: '' }]);
39 | };
40 |
41 | const updateEntry = (index: number, field: keyof KLVBuildEntry, value: string) => {
42 | const newEntries = [...entries];
43 | newEntries[index][field] = value;
44 | setEntries(newEntries);
45 | };
46 |
47 | const removeEntry = (index: number) => {
48 | if (entries.length > 1) {
49 | setEntries(entries.filter((_, i) => i !== index));
50 | }
51 | };
52 |
53 | const buildKLV = () => {
54 | if (hasDuplicates()) {
55 | return;
56 | }
57 | const klvString = KLVParser.build(entries);
58 | if (klvString) {
59 | onBuild(klvString);
60 | }
61 | };
62 |
63 | const clearAll = () => {
64 | setEntries([{ key: '002', value: '' }]);
65 | setSearchKey('');
66 | };
67 |
68 | const copyPreview = async () => {
69 | const klvString = KLVParser.build(entries);
70 | if (klvString) {
71 | await navigator.clipboard.writeText(klvString);
72 | setCopied(true);
73 | setTimeout(() => setCopied(false), 2000);
74 | }
75 | };
76 |
77 | const getFilteredDefinitions = () => {
78 | if (!searchKey.trim()) return Object.entries(KLVParser.definitions);
79 | const term = searchKey.toLowerCase();
80 | return Object.entries(KLVParser.definitions).filter(([key, name]) =>
81 | key.includes(term) || name.toLowerCase().includes(term)
82 | );
83 | };
84 |
85 | return (
86 |
87 |
88 |
89 |
KLV Builder
90 |
Build KLV strings by adding key-value pairs
91 |
92 |
93 |
100 |
101 |
102 |
103 | {/* Stats Bar */}
104 |
105 |
106 |
107 |
108 | {entries.length} {entries.length === 1 ? 'entry' : 'entries'}
109 |
110 |
111 |
112 | {entries.filter(e => e.value).length} with values
113 |
114 | {hasDuplicates() && (
115 |
116 |
117 |
{getDuplicateKeys().size} duplicate keys
118 |
119 | )}
120 |
121 |
122 |
123 | {entries.map((entry, index) => {
124 | const duplicateKeys = getDuplicateKeys();
125 | const isDuplicate = duplicateKeys.has(entry.key);
126 | const keyName = KLVParser.definitions[entry.key as keyof typeof KLVParser.definitions];
127 |
128 | return (
129 |
130 | {/* Entry Number Badge */}
131 |
132 |
133 | {index + 1}
134 |
135 |
136 |
137 | {/* Key Selection */}
138 |
139 |
142 |
153 | {keyName && !isDuplicate && (
154 |
155 | {keyName}
156 |
157 | )}
158 |
159 |
160 | {/* Value Input */}
161 |
162 |
168 |
updateEntry(index, 'value', e.target.value)}
172 | placeholder="Enter hex value..."
173 | className="w-full p-2 border border-gray-300 rounded text-sm focus:outline-none focus:ring-2 focus:ring-blue-300 focus:border-blue-300"
174 | />
175 | {entry.value && (
176 |
177 | ✓ Hex length: {Math.floor(entry.value.length / 2).toString(16).toUpperCase().padStart(2, '0')}
178 |
179 | )}
180 |
181 |
182 | {/* Delete Button */}
183 |
184 |
192 |
193 |
194 | );
195 | })}
196 |
197 |
198 | {/* Error Banner */}
199 | {hasDuplicates() && (
200 |
201 |
202 |
203 |
Duplicate Keys Detected
204 |
Each key must be unique. Please change or remove duplicate entries before building.
205 |
206 |
207 | )}
208 |
209 | {/* Action Buttons */}
210 |
211 |
218 |
227 |
228 |
229 | {/* Preview Section */}
230 | {entries.some(e => e.value) && !hasDuplicates() && (
231 |
232 |
233 |
237 |
253 |
254 |
255 |
256 | {KLVParser.build(entries)}
257 |
258 |
259 |
260 | Total length: {KLVParser.build(entries)?.length || 0} characters
261 |
262 |
263 | )}
264 |
265 | );
266 | };
267 |
268 | export default KLVBuilder;
--------------------------------------------------------------------------------
/src/tests/components/ExportPanel.test.tsx:
--------------------------------------------------------------------------------
1 | import { render, screen, cleanup } from '@testing-library/react';
2 | import userEvent from '@testing-library/user-event';
3 | import ExportPanel from '../../components/ExportPanel';
4 | import { KLVEntry } from '../../utils/KLVParser';
5 | import { mockURLAPIs, mockDOMFileDownload } from '../helpers/testUtils';
6 | import { vi } from 'vitest';
7 |
8 | // Mock Blob constructor
9 | class BlobMock {
10 | content: any[];
11 | options: any;
12 | size: number;
13 | type: string;
14 |
15 | constructor(content: any[], options: any = {}) {
16 | this.content = content;
17 | this.options = options;
18 | this.type = options.type || '';
19 | this.size = content[0]?.length || 0;
20 | }
21 | }
22 |
23 | const originalBlob = globalThis.Blob;
24 | (globalThis as any).Blob = BlobMock;
25 | vi.spyOn(globalThis as any, 'Blob');
26 |
27 | let domMocks: any;
28 | let urlMocks: any;
29 |
30 | describe('ExportPanel', () => {
31 | const mockResults: KLVEntry[] = [
32 | {
33 | key: '002',
34 | len: 6,
35 | value: 'AB48DE',
36 | pos: 0,
37 | name: 'Tracking Number'
38 | },
39 | {
40 | key: '026',
41 | len: 4,
42 | value: '4577',
43 | pos: 11,
44 | name: 'Merchant Category Code'
45 | }
46 | ];
47 |
48 | beforeEach(() => {
49 | vi.clearAllMocks();
50 | });
51 |
52 | afterEach(() => {
53 | cleanup();
54 | domMocks?.cleanup();
55 | });
56 |
57 | describe('Rendering', () => {
58 | it('should render export buttons when results are provided', () => {
59 | render();
60 |
61 | expect(screen.getByText('JSON')).toBeInTheDocument();
62 | expect(screen.getByText('CSV')).toBeInTheDocument();
63 | expect(screen.getByText('Table')).toBeInTheDocument();
64 | });
65 |
66 | it('should not render when results array is empty', () => {
67 | const { container } = render();
68 | expect(container.firstChild).toBeNull();
69 | });
70 |
71 | it('should have correct button styling and titles', () => {
72 | render();
73 |
74 | const jsonButton = screen.getByText('JSON');
75 | const csvButton = screen.getByText('CSV');
76 | const tableButton = screen.getByText('Table');
77 |
78 | expect(jsonButton).toHaveClass('bg-green-100', 'text-green-800');
79 | expect(csvButton).toHaveClass('bg-blue-100', 'text-blue-800');
80 | expect(tableButton).toHaveClass('bg-purple-100', 'text-purple-800');
81 |
82 | expect(jsonButton).toHaveAttribute('title', 'Export as JSON');
83 | expect(csvButton).toHaveAttribute('title', 'Export as CSV');
84 | expect(tableButton).toHaveAttribute('title', 'Export as Table');
85 | });
86 |
87 | it('should handle single result correctly', () => {
88 | const singleResult: KLVEntry[] = [mockResults[0]];
89 |
90 | render();
91 |
92 | expect(screen.getByText('JSON')).toBeInTheDocument();
93 | expect(screen.getByText('CSV')).toBeInTheDocument();
94 | expect(screen.getByText('Table')).toBeInTheDocument();
95 | });
96 | });
97 |
98 | describe('Export Functionality', () => {
99 | it('should trigger JSON export when JSON button is clicked', async () => {
100 | const user = userEvent.setup();
101 | render();
102 |
103 | // Set up mocks after rendering
104 | domMocks = mockDOMFileDownload();
105 | urlMocks = mockURLAPIs();
106 |
107 | const jsonButton = screen.getByText('JSON');
108 | await user.click(jsonButton);
109 |
110 | expect(globalThis.Blob).toHaveBeenCalledWith(
111 | [expect.stringContaining('"key": "002"')],
112 | { type: 'application/json' }
113 | );
114 | expect(urlMocks.mockCreateObjectURL).toHaveBeenCalled();
115 | expect(document.createElement).toHaveBeenCalledWith('a');
116 | expect(domMocks.mocks.mockAppendChild).toHaveBeenCalled();
117 | expect(domMocks.mocks.mockClick).toHaveBeenCalled();
118 | expect(domMocks.mocks.mockRemoveChild).toHaveBeenCalled();
119 | expect(urlMocks.mockRevokeObjectURL).toHaveBeenCalledWith('mock-blob-url');
120 | });
121 |
122 | it('should trigger CSV export when CSV button is clicked', async () => {
123 | const user = userEvent.setup();
124 | render();
125 |
126 | // Set up mocks after rendering
127 | domMocks = mockDOMFileDownload();
128 | urlMocks = mockURLAPIs();
129 |
130 | const csvButton = screen.getByText('CSV');
131 | await user.click(csvButton);
132 |
133 | expect(globalThis.Blob).toHaveBeenCalledWith(
134 | [expect.stringContaining('Key,Name,Length,Value,Position')],
135 | { type: 'text/csv' }
136 | );
137 | expect(urlMocks.mockCreateObjectURL).toHaveBeenCalled();
138 | expect(domMocks.mocks.mockClick).toHaveBeenCalled();
139 | });
140 |
141 | it('should trigger Table export when Table button is clicked', async () => {
142 | const user = userEvent.setup();
143 | render();
144 |
145 | // Set up mocks after rendering
146 | domMocks = mockDOMFileDownload();
147 | urlMocks = mockURLAPIs();
148 |
149 | const tableButton = screen.getByText('Table');
150 | await user.click(tableButton);
151 |
152 | expect(globalThis.Blob).toHaveBeenCalledWith(
153 | [expect.stringContaining('002')],
154 | { type: 'text/plain' }
155 | );
156 | expect(urlMocks.mockCreateObjectURL).toHaveBeenCalled();
157 | expect(domMocks.mocks.mockClick).toHaveBeenCalled();
158 | });
159 |
160 | it('should generate filename with timestamp for JSON export', async () => {
161 | const user = userEvent.setup();
162 | render();
163 |
164 | // Set up mocks after rendering
165 | domMocks = mockDOMFileDownload();
166 | urlMocks = mockURLAPIs();
167 |
168 | const jsonButton = screen.getByText('JSON');
169 | await user.click(jsonButton);
170 |
171 | expect(domMocks.mocks.mockElement.download).toMatch(/klv-data-\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}\.json/);
172 | });
173 |
174 | it('should generate filename with timestamp for CSV export', async () => {
175 | const user = userEvent.setup();
176 | render();
177 |
178 | // Set up mocks after rendering
179 | domMocks = mockDOMFileDownload();
180 | urlMocks = mockURLAPIs();
181 |
182 | const csvButton = screen.getByText('CSV');
183 | await user.click(csvButton);
184 |
185 | expect(domMocks.mocks.mockElement.download).toMatch(/klv-data-\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}\.csv/);
186 | });
187 |
188 | it('should generate filename with timestamp for Table export', async () => {
189 | const user = userEvent.setup();
190 | render();
191 |
192 | // Set up mocks after rendering
193 | domMocks = mockDOMFileDownload();
194 | urlMocks = mockURLAPIs();
195 |
196 | const tableButton = screen.getByText('Table');
197 | await user.click(tableButton);
198 |
199 | expect(domMocks.mocks.mockElement.download).toMatch(/klv-data-\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}\.txt/);
200 | });
201 |
202 | it('should set correct href for download link', async () => {
203 | const user = userEvent.setup();
204 | render();
205 |
206 | // Set up mocks after rendering
207 | domMocks = mockDOMFileDownload();
208 | urlMocks = mockURLAPIs();
209 |
210 | const jsonButton = screen.getByText('JSON');
211 | await user.click(jsonButton);
212 |
213 | expect(domMocks.mocks.mockElement.href).toBe('mock-blob-url');
214 | });
215 | });
216 |
217 | describe('DOM Cleanup', () => {
218 | it('should properly clean up DOM elements after export', async () => {
219 | const user = userEvent.setup();
220 | render();
221 |
222 | // Set up mocks after rendering
223 | domMocks = mockDOMFileDownload();
224 | urlMocks = mockURLAPIs();
225 |
226 | const jsonButton = screen.getByText('JSON');
227 | await user.click(jsonButton);
228 |
229 | expect(domMocks.mocks.mockAppendChild).toHaveBeenCalledWith(domMocks.mocks.mockElement);
230 | expect(domMocks.mocks.mockRemoveChild).toHaveBeenCalledWith(domMocks.mocks.mockElement);
231 | expect(urlMocks.mockRevokeObjectURL).toHaveBeenCalledWith('mock-blob-url');
232 | });
233 | });
234 |
235 | describe('Edge Cases', () => {
236 | it('should handle empty values in results', async () => {
237 | const emptyResults: KLVEntry[] = [
238 | {
239 | key: '002',
240 | len: 0,
241 | value: '',
242 | pos: 0,
243 | name: 'Empty Value'
244 | }
245 | ];
246 |
247 | const user = userEvent.setup();
248 | render();
249 |
250 | // Set up mocks after rendering
251 | domMocks = mockDOMFileDownload();
252 | urlMocks = mockURLAPIs();
253 |
254 | const jsonButton = screen.getByText('JSON');
255 | await user.click(jsonButton);
256 |
257 | expect(globalThis.Blob).toHaveBeenCalled();
258 | expect(domMocks.mocks.mockClick).toHaveBeenCalled();
259 | });
260 |
261 | it('should handle special characters in values', async () => {
262 | const specialResults: KLVEntry[] = [
263 | {
264 | key: '002',
265 | len: 10,
266 | value: 'Test"Value,',
267 | pos: 0,
268 | name: 'Special Chars'
269 | }
270 | ];
271 |
272 | const user = userEvent.setup();
273 | render();
274 |
275 | // Set up mocks after rendering
276 | domMocks = mockDOMFileDownload();
277 | urlMocks = mockURLAPIs();
278 |
279 | const csvButton = screen.getByText('CSV');
280 | await user.click(csvButton);
281 |
282 | expect(globalThis.Blob).toHaveBeenCalledWith(
283 | [expect.stringContaining('Test"Value,')],
284 | { type: 'text/csv' }
285 | );
286 | expect(domMocks.mocks.mockClick).toHaveBeenCalled();
287 | });
288 | });
289 |
290 | describe('Component Props', () => {
291 | it('should re-render when results prop changes', () => {
292 | const { rerender } = render();
293 |
294 | expect(screen.queryByText('JSON')).not.toBeInTheDocument();
295 |
296 | rerender();
297 |
298 | expect(screen.getByText('JSON')).toBeInTheDocument();
299 | });
300 |
301 | it('should handle results with different key types', async () => {
302 | const mixedResults: KLVEntry[] = [
303 | {
304 | key: '002',
305 | len: 4,
306 | value: 'TEST',
307 | pos: 0,
308 | name: 'Known Key'
309 | },
310 | {
311 | key: '999',
312 | len: 3,
313 | value: 'XYZ',
314 | pos: 9,
315 | name: 'Unknown'
316 | }
317 | ];
318 |
319 | const user = userEvent.setup();
320 | render();
321 |
322 | // Set up mocks after rendering
323 | domMocks = mockDOMFileDownload();
324 | urlMocks = mockURLAPIs();
325 |
326 | const tableButton = screen.getByText('Table');
327 | await user.click(tableButton);
328 |
329 | expect(globalThis.Blob).toHaveBeenCalledWith(
330 | [expect.stringContaining('002') && expect.stringContaining('999')],
331 | { type: 'text/plain' }
332 | );
333 | });
334 | });
335 | });
--------------------------------------------------------------------------------
/src/tests/utils/KLVParser.test.ts:
--------------------------------------------------------------------------------
1 | import KLVParser, { KLVEntry, KLVParseResult, KLVValidationResult } from '../../utils/KLVParser';
2 |
3 |
4 | describe('KLVParser', () => {
5 | describe('parse', () => {
6 | it('should parse a valid single KLV entry', () => {
7 | const input = '00206AB48DE';
8 | const result: KLVParseResult = KLVParser.parse(input);
9 |
10 | expect(result.errors).toHaveLength(0);
11 | expect(result.results).toHaveLength(1);
12 | expect(result.results[0]).toEqual({
13 | key: '002',
14 | len: 6,
15 | value: 'AB48DE',
16 | pos: 0,
17 | name: 'Tracking Number'
18 | });
19 | });
20 |
21 | it('should parse multiple KLV entries', () => {
22 | const input = '00206AB48DE00200';
23 | const result: KLVParseResult = KLVParser.parse(input);
24 |
25 | expect(result.errors).toHaveLength(0);
26 | expect(result.results).toHaveLength(2);
27 |
28 | expect(result.results[0]).toEqual({
29 | key: '002',
30 | len: 6,
31 | value: 'AB48DE',
32 | pos: 0,
33 | name: 'Tracking Number'
34 | });
35 |
36 | expect(result.results[1]).toEqual({
37 | key: '002',
38 | len: 0,
39 | value: '',
40 | pos: 11,
41 | name: 'Tracking Number'
42 | });
43 | });
44 |
45 | it('should handle KLV entries with spaces and normalize them', () => {
46 | const input = '002 06 AB48DE 026 04 4577';
47 | const result: KLVParseResult = KLVParser.parse(input);
48 |
49 | expect(result.errors).toHaveLength(0);
50 | expect(result.results).toHaveLength(2);
51 | expect(result.results[0].value).toBe('AB48DE');
52 | expect(result.results[1].value).toBe('4577');
53 | });
54 |
55 | it('should return error for incomplete entry', () => {
56 | const input = '002';
57 | const result: KLVParseResult = KLVParser.parse(input);
58 |
59 | expect(result.errors).toHaveLength(1);
60 | expect(result.errors[0]).toBe('Incomplete entry at position 0');
61 | expect(result.results).toHaveLength(0);
62 | });
63 |
64 | it('should return error for invalid key format', () => {
65 | const input = 'XYZ0612345';
66 | const result: KLVParseResult = KLVParser.parse(input);
67 |
68 | expect(result.errors).toHaveLength(1);
69 | expect(result.errors[0]).toBe('Invalid format at position 0');
70 | expect(result.results).toHaveLength(0);
71 | });
72 |
73 | it('should return error for invalid length format', () => {
74 | const input = '002XY12345';
75 | const result: KLVParseResult = KLVParser.parse(input);
76 |
77 | expect(result.errors).toHaveLength(1);
78 | expect(result.errors[0]).toBe('Invalid format at position 0');
79 | expect(result.results).toHaveLength(0);
80 | });
81 |
82 | it('should return error for incomplete value', () => {
83 | const input = '00210123';
84 | const result: KLVParseResult = KLVParser.parse(input);
85 |
86 | expect(result.errors).toHaveLength(1);
87 | expect(result.errors[0]).toBe('Incomplete value at position 5');
88 | expect(result.results).toHaveLength(0);
89 | });
90 |
91 | it('should handle zero-length values', () => {
92 | const input = '00200';
93 | const result: KLVParseResult = KLVParser.parse(input);
94 |
95 | expect(result.errors).toHaveLength(0);
96 | expect(result.results).toHaveLength(1);
97 | expect(result.results[0]).toEqual({
98 | key: '002',
99 | len: 0,
100 | value: '',
101 | pos: 0,
102 | name: 'Tracking Number'
103 | });
104 | });
105 |
106 | it('should identify unknown keys', () => {
107 | const input = '99905TEST1';
108 | const result: KLVParseResult = KLVParser.parse(input);
109 |
110 | expect(result.errors).toHaveLength(0);
111 | expect(result.results).toHaveLength(1);
112 | expect(result.results[0].name).toBe('Generic Key');
113 | });
114 |
115 | it('should handle empty input', () => {
116 | const input = '';
117 | const result: KLVParseResult = KLVParser.parse(input);
118 |
119 | expect(result.errors).toHaveLength(0);
120 | expect(result.results).toHaveLength(0);
121 | });
122 |
123 | it('should handle whitespace-only input', () => {
124 | const input = ' \n\t ';
125 | const result: KLVParseResult = KLVParser.parse(input);
126 |
127 | expect(result.errors).toHaveLength(0);
128 | expect(result.results).toHaveLength(0);
129 | });
130 | });
131 |
132 | describe('validate', () => {
133 | it('should validate correct KLV string', () => {
134 | const input = '00206AB48DE02604TEST';
135 | const result: KLVValidationResult = KLVParser.validate(input);
136 |
137 | expect(result.isValid).toBe(true);
138 | expect(result.entriesCount).toBe(2);
139 | expect(result.errors).toHaveLength(0);
140 | expect(result.totalLength).toBe(20);
141 | });
142 |
143 | it('should return invalid for malformed KLV string', () => {
144 | const input = '00206AB48DEXYZ';
145 | const result: KLVValidationResult = KLVParser.validate(input);
146 |
147 | expect(result.isValid).toBe(false);
148 | expect(result.entriesCount).toBe(1);
149 | expect(result.errors.length).toBeGreaterThan(0);
150 | });
151 |
152 | it('should handle empty string validation', () => {
153 | const input = '';
154 | const result: KLVValidationResult = KLVParser.validate(input);
155 |
156 | expect(result.isValid).toBe(true);
157 | expect(result.entriesCount).toBe(0);
158 | expect(result.errors).toHaveLength(0);
159 | expect(result.totalLength).toBe(0);
160 | });
161 | });
162 |
163 | describe('export', () => {
164 | const sampleResults: KLVEntry[] = [
165 | {
166 | key: '002',
167 | len: 6,
168 | value: 'AB48DE',
169 | pos: 0,
170 | name: 'Tracking Number'
171 | },
172 | {
173 | key: '026',
174 | len: 4,
175 | value: '4577',
176 | pos: 11,
177 | name: 'Merchant Category Code'
178 | }
179 | ];
180 |
181 | it('should export to JSON format', () => {
182 | const result = KLVParser.export(sampleResults, 'json');
183 | const parsed = JSON.parse(result);
184 |
185 | expect(parsed).toHaveLength(2);
186 | expect(parsed[0].key).toBe('002');
187 | expect(parsed[0].value).toBe('AB48DE');
188 | expect(parsed[1].key).toBe('026');
189 | expect(parsed[1].value).toBe('4577');
190 | });
191 |
192 | it('should export to CSV format', () => {
193 | const result = KLVParser.export(sampleResults, 'csv');
194 | const lines = result.split('\n');
195 |
196 | expect(lines[0]).toBe('Key,Name,Length,Value,Position');
197 | expect(lines[1]).toBe('"002","Tracking Number","6","AB48DE","0"');
198 | expect(lines[2]).toBe('"026","Merchant Category Code","4","4577","11"');
199 | });
200 |
201 | it('should export to table format', () => {
202 | const result = KLVParser.export(sampleResults, 'table');
203 | const lines = result.split('\n');
204 |
205 | expect(lines[0]).toContain('002');
206 | expect(lines[0]).toContain('Tracking Number');
207 | expect(lines[0]).toContain('AB48DE');
208 | expect(lines[1]).toContain('026');
209 | expect(lines[1]).toContain('Merchant Category Code');
210 | expect(lines[1]).toContain('4577');
211 | });
212 |
213 | it('should default to JSON format for unknown format', () => {
214 | const result = KLVParser.export(sampleResults, 'unknown' as any);
215 | expect(() => JSON.parse(result)).not.toThrow();
216 | });
217 |
218 | it('should handle empty results array', () => {
219 | const result = KLVParser.export([], 'json');
220 | expect(JSON.parse(result)).toEqual([]);
221 | });
222 | });
223 |
224 | describe('build', () => {
225 | it('should build KLV string from entries', () => {
226 | const entries = [
227 | { key: '002', value: 'TEST123' },
228 | { key: '26', value: '1234' }
229 | ];
230 |
231 | const result = KLVParser.build(entries);
232 | expect(result).toBe('00207TEST123026041234');
233 | });
234 |
235 | it('should pad keys with leading zeros', () => {
236 | const entries = [
237 | { key: '2', value: 'TEST' },
238 | { key: '26', value: 'ABCD' }
239 | ];
240 |
241 | const result = KLVParser.build(entries);
242 | expect(result).toBe('00204TEST02604ABCD');
243 | });
244 |
245 | it('should handle non-empty values correctly', () => {
246 | const entries = [
247 | { key: '002', value: 'A' }
248 | ];
249 |
250 | const result = KLVParser.build(entries);
251 | expect(result).toBe('00201A');
252 | });
253 |
254 | it('should filter out entries with empty values', () => {
255 | const entries = [
256 | { key: '002', value: '' }
257 | ];
258 |
259 | const result = KLVParser.build(entries);
260 | expect(result).toBe('');
261 | });
262 |
263 | it('should filter out entries without key or value', () => {
264 | const entries = [
265 | { key: '002', value: 'VALID' },
266 | { key: '', value: 'NO_KEY' },
267 | { key: '026', value: '' },
268 | { key: '041', value: 'ALSO_VALID' }
269 | ];
270 |
271 | const result = KLVParser.build(entries);
272 | expect(result).toBe('00205VALID04110ALSO_VALID');
273 | });
274 |
275 | it('should handle empty entries array', () => {
276 | const result = KLVParser.build([]);
277 | expect(result).toBe('');
278 | });
279 |
280 | it('should handle large values correctly', () => {
281 | const longValue = 'A'.repeat(99);
282 | const entries = [
283 | { key: '002', value: longValue }
284 | ];
285 |
286 | const result = KLVParser.build(entries);
287 | expect(result).toBe(`00299${longValue}`);
288 | });
289 | });
290 |
291 | describe('definitions', () => {
292 | it('should contain known KLV keys', () => {
293 | expect(KLVParser.definitions['002']).toBe('Tracking Number');
294 | expect(KLVParser.definitions['026']).toBe('Merchant Category Code');
295 | expect(KLVParser.definitions['042']).toBe('Merchant Identifier');
296 | expect(KLVParser.definitions['999']).toBe('Generic Key');
297 | });
298 |
299 | it('should have comprehensive key coverage', () => {
300 | const keyCount = Object.keys(KLVParser.definitions).length;
301 | expect(keyCount).toBeGreaterThan(100); // Should have over 100 defined keys
302 | });
303 |
304 | it('should not have duplicate values for different keys', () => {
305 | const values = Object.values(KLVParser.definitions);
306 | const uniqueValues = new Set(values);
307 | expect(uniqueValues.size).toBe(values.length);
308 | });
309 | });
310 |
311 | describe('currency mapping', () => {
312 | it('should format USD currency code correctly', () => {
313 | const result = KLVParser.formatCurrency('840', '049');
314 | expect(result).not.toBeNull();
315 | expect(result?.formattedValue).toBe('🇺🇸 USD - US Dollar');
316 | expect(result?.currencyInfo?.code).toBe('USD');
317 | expect(result?.currencyInfo?.name).toBe('US Dollar');
318 | expect(result?.currencyInfo?.flag).toBe('🇺🇸');
319 | });
320 |
321 | it('should format EUR currency code correctly', () => {
322 | const result = KLVParser.formatCurrency('978', '049');
323 | expect(result).not.toBeNull();
324 | expect(result?.formattedValue).toBe('🇪🇺 EUR - Euro');
325 | expect(result?.currencyInfo?.code).toBe('EUR');
326 | });
327 |
328 | it('should handle currency codes with leading zeros', () => {
329 | const result = KLVParser.formatCurrency('36', '049');
330 | expect(result).not.toBeNull();
331 | expect(result?.formattedValue).toBe('🇦🇺 AUD - Australian Dollar');
332 | });
333 |
334 | it('should return null for non-currency fields', () => {
335 | const result = KLVParser.formatCurrency('840', '002');
336 | expect(result).toBeNull();
337 | });
338 |
339 | it('should handle unknown currency codes', () => {
340 | const result = KLVParser.formatCurrency('999', '049');
341 | expect(result).not.toBeNull();
342 | expect(result?.formattedValue).toBe('999 (Unknown Currency Code)');
343 | expect(result?.currencyInfo).toBeUndefined();
344 | });
345 |
346 | it('should integrate currency formatting in parse function', () => {
347 | const klvWithCurrency = '04903840'; // Original Currency Code = 840 (USD)
348 | const result = KLVParser.parse(klvWithCurrency);
349 |
350 | expect(result.results).toHaveLength(1);
351 | expect(result.results[0].key).toBe('049');
352 | expect(result.results[0].value).toBe('840');
353 | expect(result.results[0].formattedValue).toBe('🇺🇸 USD - US Dollar');
354 | expect(result.results[0].currencyInfo?.code).toBe('USD');
355 | expect(result.results[0].name).toBe('Original Currency Code');
356 | });
357 |
358 | it('should have comprehensive currency mapping', () => {
359 | const mappingKeys = Object.keys(KLVParser.currencyMapping);
360 | expect(mappingKeys.length).toBeGreaterThan(50); // Should have over 50 currencies
361 |
362 | // Check some key currencies exist
363 | expect(KLVParser.currencyMapping['840']).toBeDefined(); // USD
364 | expect(KLVParser.currencyMapping['978']).toBeDefined(); // EUR
365 | expect(KLVParser.currencyMapping['826']).toBeDefined(); // GBP
366 | expect(KLVParser.currencyMapping['392']).toBeDefined(); // JPY
367 | });
368 | });
369 | });
--------------------------------------------------------------------------------
/src/utils/KLVParser.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * KLV Parser Utility
3 | * Handles parsing and validation of Key-Length-Value data format
4 | */
5 |
6 | export interface KLVEntry {
7 | key: string;
8 | len: number;
9 | value: string;
10 | pos: number;
11 | name: string;
12 | formattedValue?: string;
13 | currencyInfo?: {
14 | code: string;
15 | name: string;
16 | flag: string;
17 | };
18 | }
19 |
20 | export interface KLVParseResult {
21 | results: KLVEntry[];
22 | errors: string[];
23 | }
24 |
25 | export interface KLVValidationResult {
26 | isValid: boolean;
27 | entriesCount: number;
28 | errors: string[];
29 | totalLength: number;
30 | }
31 |
32 | export interface KLVBuildEntry {
33 | key: string;
34 | value: string;
35 | }
36 |
37 | type ExportFormat = 'json' | 'csv' | 'table';
38 |
39 | const KLVParser = {
40 | // Complete KLV definitions
41 | definitions: {
42 | '002': 'Tracking Number',
43 | '004': 'Original Transaction Amount',
44 | '010': 'Conversion Rate',
45 | '026': 'Merchant Category Code',
46 | '032': 'Acquiring Institution Code',
47 | '037': 'Retrieval Reference Number',
48 | '041': 'Terminal ID',
49 | '042': 'Merchant Identifier',
50 | '043': 'Merchant Description',
51 | '044': 'Merchant Name',
52 | '045': 'Transaction Type Identifier',
53 | '048': 'Fraud Scoring Data',
54 | '049': 'Original Currency Code',
55 | '050': 'From Account',
56 | '052': 'Pin Block',
57 | '061': 'POS Data',
58 | '063': 'TraceID',
59 | '067': 'Extended Payment Code',
60 | '068': 'Is Recurring',
61 | '069': 'Message Reason Code',
62 | '085': 'Markup Amount',
63 | '108': 'Recipient Name',
64 | '109': 'Recipient Address',
65 | '110': 'Recipient Account Number',
66 | '111': 'Recipient Account Number Type',
67 | '250': 'Capture Mode',
68 | '251': 'Network',
69 | '252': 'Fee Type',
70 | '253': 'Last Four Digits PAN',
71 | '254': 'MDES Digitized PAN',
72 | '255': 'MDES Digitized Wallet ID',
73 | '256': 'Adjustment Reason',
74 | '257': 'Reference ID',
75 | '258': 'Markup Type',
76 | '259': 'Acquirer Country',
77 | '260': 'Mobile Number',
78 | '261': 'Transaction Fee Amount',
79 | '262': 'Transaction Subtype',
80 | '263': 'Card Issuer Data',
81 | '264': 'Tax',
82 | '265': 'Tax Amount Base',
83 | '266': 'Retailer Data',
84 | '267': 'IAC Tax Amount',
85 | '268': 'Number of Installments',
86 | '269': 'Customer ID',
87 | '270': 'Security Services Data',
88 | '271': 'On Behalf of Services',
89 | '272': 'Original Merchant Description',
90 | '273': 'Installments Financing Type',
91 | '274': 'Status',
92 | '275': 'Installments Grace Period',
93 | '276': 'Installments Type of Credit',
94 | '277': 'Payments Initiator',
95 | '278': 'Payment Initiator Subtype',
96 | '300': 'Additional Amount',
97 | '301': 'Second Additional Amount',
98 | '302': 'Cashback POS Currency Code',
99 | '303': 'Cashback POS Amount',
100 | '400': 'Sender Name',
101 | '401': 'Sender Address',
102 | '402': 'Sender City',
103 | '403': 'Sender State',
104 | '404': 'Sender Country',
105 | '405': 'Sanction Screening Score',
106 | '406': 'Business Application Identifier',
107 | '408': 'Special Condition Indicator',
108 | '409': 'Business Tax ID',
109 | '410': 'Individual Tax ID',
110 | '411': 'Source of Funds',
111 | '412': 'Sender Account Number',
112 | '413': 'Sender Account Number Type',
113 | '414': 'MVV',
114 | '415': 'Sender Reference Number',
115 | '416': 'Is AFD Transaction',
116 | '417': 'Acquirer Fee Amount',
117 | '418': 'Address Verification Result',
118 | '419': 'Postal Code / ZIP Code',
119 | '420': 'Street Address',
120 | '421': 'Sender Date of Birth',
121 | '422': 'OCT Activity Check Result',
122 | '423': 'Sender Postal Code',
123 | '424': 'Recipient City',
124 | '425': 'Recipient Country',
125 | '900': '3D Secure OTP',
126 | '901': 'Digitization Activation',
127 | '902': 'Digitization Activation Method Type',
128 | '903': 'Digitization Activation Method Value',
129 | '904': 'Digitization Activation Expiry',
130 | '905': 'Digitization Final Tokenization Decision',
131 | '906': 'Device Name',
132 | '910': 'Digitized Device ID',
133 | '911': 'Digitized PAN Expiry',
134 | '912': 'Digitized FPAN Masked',
135 | '913': 'Token Unique Reference',
136 | '915': 'Digitized Token Requestor ID',
137 | '916': 'Visa Digitized PAN',
138 | '917': 'Visa Token Type',
139 | '920': 'POS Transaction Status',
140 | '921': 'POS Transaction Security',
141 | '922': 'POS Authorisation Lifecycle',
142 | '923': 'Digitization Event Type',
143 | '924': 'Digitization Event Reason Code',
144 | '925': 'Supports Partial Auth',
145 | '929': 'Digitization Path',
146 | '930': 'Wallet Recommendation',
147 | '931': 'Tokenization PAN Source',
148 | '932': 'Unique Transaction Reference',
149 | '933': 'Transaction Purpose',
150 | '934': '3D Secure OTP RefCode',
151 | '999': 'Generic Key'
152 | } as const,
153 |
154 | /**
155 | * ISO 4217 Currency Codes mapping to currency names and country flags
156 | * Based on https://www.iban.com/currency-codes
157 | */
158 | currencyMapping: {
159 | // Major currencies
160 | '840': { name: 'US Dollar', code: 'USD', flag: '🇺🇸' },
161 | '978': { name: 'Euro', code: 'EUR', flag: '🇪🇺' },
162 | '826': { name: 'Pound Sterling', code: 'GBP', flag: '🇬🇧' },
163 | '392': { name: 'Japanese Yen', code: 'JPY', flag: '🇯🇵' },
164 | '756': { name: 'Swiss Franc', code: 'CHF', flag: '🇨🇭' },
165 | '124': { name: 'Canadian Dollar', code: 'CAD', flag: '🇨🇦' },
166 | '036': { name: 'Australian Dollar', code: 'AUD', flag: '🇦🇺' },
167 | '554': { name: 'New Zealand Dollar', code: 'NZD', flag: '🇳🇿' },
168 | '156': { name: 'Chinese Yuan', code: 'CNY', flag: '🇨🇳' },
169 | '356': { name: 'Indian Rupee', code: 'INR', flag: '🇮🇳' },
170 |
171 | // European currencies
172 | '752': { name: 'Swedish Krona', code: 'SEK', flag: '🇸🇪' },
173 | '578': { name: 'Norwegian Krone', code: 'NOK', flag: '🇳🇴' },
174 | '208': { name: 'Danish Krone', code: 'DKK', flag: '🇩🇰' },
175 | '985': { name: 'Polish Zloty', code: 'PLN', flag: '🇵🇱' },
176 | '203': { name: 'Czech Koruna', code: 'CZK', flag: '🇨🇿' },
177 | '348': { name: 'Hungarian Forint', code: 'HUF', flag: '🇭🇺' },
178 | '946': { name: 'Romanian Leu', code: 'RON', flag: '🇷🇴' },
179 | '975': { name: 'Bulgarian Lev', code: 'BGN', flag: '🇧🇬' },
180 | '191': { name: 'Croatian Kuna', code: 'HRK', flag: '🇭🇷' },
181 | '941': { name: 'Serbian Dinar', code: 'RSD', flag: '🇷🇸' },
182 |
183 | // Asia Pacific
184 | '702': { name: 'Singapore Dollar', code: 'SGD', flag: '🇸🇬' },
185 | '344': { name: 'Hong Kong Dollar', code: 'HKD', flag: '🇭🇰' },
186 | '410': { name: 'Korean Won', code: 'KRW', flag: '🇰🇷' },
187 | '764': { name: 'Thai Baht', code: 'THB', flag: '🇹🇭' },
188 | '458': { name: 'Malaysian Ringgit', code: 'MYR', flag: '🇲🇾' },
189 | '360': { name: 'Indonesian Rupiah', code: 'IDR', flag: '🇮🇩' },
190 | '608': { name: 'Philippine Peso', code: 'PHP', flag: '🇵🇭' },
191 | '704': { name: 'Vietnamese Dong', code: 'VND', flag: '🇻🇳' },
192 | '096': { name: 'Brunei Dollar', code: 'BND', flag: '🇧🇳' },
193 |
194 | // Americas
195 | '484': { name: 'Mexican Peso', code: 'MXN', flag: '🇲🇽' },
196 | '986': { name: 'Brazilian Real', code: 'BRL', flag: '🇧🇷' },
197 | '032': { name: 'Argentine Peso', code: 'ARS', flag: '🇦🇷' },
198 | '152': { name: 'Chilean Peso', code: 'CLP', flag: '🇨🇱' },
199 | '604': { name: 'Peruvian Sol', code: 'PEN', flag: '🇵🇪' },
200 | '170': { name: 'Colombian Peso', code: 'COP', flag: '🇨🇴' },
201 | '858': { name: 'Uruguayan Peso', code: 'UYU', flag: '🇺🇾' },
202 | '600': { name: 'Paraguayan Guarani', code: 'PYG', flag: '🇵🇾' },
203 | '068': { name: 'Bolivian Boliviano', code: 'BOB', flag: '🇧🇴' },
204 | '218': { name: 'Ecuadorian Sucre', code: 'ECS', flag: '🇪🇨' },
205 |
206 | // Middle East & Africa
207 | '784': { name: 'UAE Dirham', code: 'AED', flag: '🇦🇪' },
208 | '682': { name: 'Saudi Riyal', code: 'SAR', flag: '🇸🇦' },
209 | '376': { name: 'Israeli New Shekel', code: 'ILS', flag: '🇮🇱' },
210 | '818': { name: 'Egyptian Pound', code: 'EGP', flag: '🇪🇬' },
211 | '710': { name: 'South African Rand', code: 'ZAR', flag: '🇿🇦' },
212 | '566': { name: 'Nigerian Naira', code: 'NGN', flag: '🇳🇬' },
213 | '404': { name: 'Kenyan Shilling', code: 'KES', flag: '🇰🇪' },
214 | '788': { name: 'Tunisian Dinar', code: 'TND', flag: '🇹🇳' },
215 | '504': { name: 'Moroccan Dirham', code: 'MAD', flag: '🇲🇦' },
216 | '012': { name: 'Algerian Dinar', code: 'DZD', flag: '🇩🇿' },
217 |
218 | // Eastern Europe & CIS
219 | '643': { name: 'Russian Ruble', code: 'RUB', flag: '🇷🇺' },
220 | '980': { name: 'Ukrainian Hryvnia', code: 'UAH', flag: '🇺🇦' },
221 | '398': { name: 'Kazakhstani Tenge', code: 'KZT', flag: '🇰🇿' },
222 | '051': { name: 'Armenian Dram', code: 'AMD', flag: '🇦🇲' },
223 | '031': { name: 'Azerbaijani Manat', code: 'AZN', flag: '🇦🇿' },
224 | '934': { name: 'Turkmenistani Manat', code: 'TMT', flag: '🇹🇲' },
225 | '860': { name: 'Uzbekistani Som', code: 'UZS', flag: '🇺🇿' },
226 | '417': { name: 'Kyrgyzstani Som', code: 'KGS', flag: '🇰🇬' },
227 | '972': { name: 'Tajikistani Somoni', code: 'TJS', flag: '🇹🇯' },
228 |
229 | // Additional major currencies
230 | '949': { name: 'Turkish Lira', code: 'TRY', flag: '🇹🇷' },
231 | '364': { name: 'Iranian Rial', code: 'IRR', flag: '🇮🇷' },
232 | '368': { name: 'Iraqi Dinar', code: 'IQD', flag: '🇮🇶' },
233 | '414': { name: 'Kuwaiti Dinar', code: 'KWD', flag: '🇰🇼' },
234 | '048': { name: 'Bahraini Dinar', code: 'BHD', flag: '🇧🇭' },
235 | '634': { name: 'Qatari Rial', code: 'QAR', flag: '🇶🇦' },
236 | '512': { name: 'Omani Rial', code: 'OMR', flag: '🇴🇲' },
237 | '422': { name: 'Lebanese Pound', code: 'LBP', flag: '🇱🇧' },
238 | '400': { name: 'Jordanian Dinar', code: 'JOD', flag: '🇯🇴' }
239 | } as const,
240 |
241 | /**
242 | * Format currency value with currency name and flag
243 | * @param value - The currency code value (ISO 4217 numeric code)
244 | * @param key - The KLV key to determine if it's a currency field
245 | * @returns Formatted currency information or null if not a currency field
246 | */
247 | formatCurrency(value: string, key: string): { formattedValue: string; currencyInfo?: { code: string; name: string; flag: string; } } | null {
248 | // Check if this is the Original Currency Code field (key 049)
249 | if (key !== '049') {
250 | return null;
251 | }
252 |
253 | // Pad the value to 3 digits if needed (some currency codes might be shorter)
254 | const paddedValue = value.padStart(3, '0');
255 | const currency = this.currencyMapping[paddedValue as keyof typeof this.currencyMapping];
256 |
257 | if (currency) {
258 | return {
259 | formattedValue: `${currency.flag} ${currency.code} - ${currency.name}`,
260 | currencyInfo: {
261 | code: currency.code,
262 | name: currency.name,
263 | flag: currency.flag
264 | }
265 | };
266 | }
267 |
268 | // If currency not found, still show the original value with indication
269 | return {
270 | formattedValue: `${value} (Unknown Currency Code)`,
271 | currencyInfo: undefined
272 | };
273 | },
274 |
275 | /**
276 | * Parse KLV string into individual components
277 | * @param klvString - The KLV data string to parse
278 | * @returns Object containing results and errors
279 | */
280 | parse(klvString: string): KLVParseResult {
281 | const results: KLVEntry[] = [];
282 | const errors: string[] = [];
283 | let pos = 0;
284 | const clean = klvString.replace(/\s/g, '');
285 |
286 | while (pos < clean.length) {
287 | if (pos + 5 > clean.length) {
288 | errors.push(`Incomplete entry at position ${pos}`);
289 | break;
290 | }
291 |
292 | const key = clean.substring(pos, pos + 3);
293 | const lenStr = clean.substring(pos + 3, pos + 5);
294 |
295 | if (!/^\d{3}$/.test(key) || !/^\d{2}$/.test(lenStr)) {
296 | errors.push(`Invalid format at position ${pos}`);
297 | break;
298 | }
299 |
300 | const len = parseInt(lenStr, 10);
301 | const valEnd = pos + 5 + len;
302 |
303 | if (valEnd > clean.length) {
304 | errors.push(`Incomplete value at position ${pos + 5}`);
305 | break;
306 | }
307 |
308 | const value = clean.substring(pos + 5, pos + 5 + len);
309 |
310 | // Create base entry
311 | const entry: KLVEntry = {
312 | key,
313 | len,
314 | value,
315 | pos,
316 | name: KLVParser.definitions[key as keyof typeof KLVParser.definitions] || 'Unknown'
317 | };
318 |
319 | // Add currency formatting if applicable
320 | const currencyFormat = KLVParser.formatCurrency(value, key);
321 | if (currencyFormat) {
322 | entry.formattedValue = currencyFormat.formattedValue;
323 | entry.currencyInfo = currencyFormat.currencyInfo;
324 | }
325 |
326 | results.push(entry);
327 | pos = valEnd;
328 | }
329 |
330 | return { results, errors };
331 | },
332 |
333 | /**
334 | * Validate KLV string format
335 | * @param klvString - The KLV data string to validate
336 | * @returns Validation result
337 | */
338 | validate(klvString: string): KLVValidationResult {
339 | const { results, errors } = KLVParser.parse(klvString);
340 | return {
341 | isValid: errors.length === 0,
342 | entriesCount: results.length,
343 | errors,
344 | totalLength: klvString.replace(/\s/g, '').length
345 | };
346 | },
347 |
348 | /**
349 | * Export results to different formats
350 | * @param results - Parsed KLV results
351 | * @param format - Export format (json, csv, table)
352 | * @returns Exported data
353 | */
354 | export(results: KLVEntry[], format: ExportFormat = 'json'): string {
355 | switch (format) {
356 | case 'json':
357 | return JSON.stringify(results, null, 2);
358 | case 'csv':
359 | const headers = 'Key,Name,Length,Value,Position\n';
360 | const rows = results.map(r =>
361 | `"${r.key}","${r.name}","${r.len}","${r.value}","${r.pos}"`
362 | ).join('\n');
363 | return headers + rows;
364 | case 'table':
365 | return results.map(r =>
366 | `${r.key.padEnd(5)} ${r.name.padEnd(30)} ${r.len.toString().padEnd(3)} ${r.value}`
367 | ).join('\n');
368 | default:
369 | return JSON.stringify(results, null, 2);
370 | }
371 | },
372 |
373 | /**
374 | * Build KLV string from entries
375 | * @param entries - Array of {key, value} objects
376 | * @returns Built KLV string
377 | */
378 | build(entries: KLVBuildEntry[]): string {
379 | return entries
380 | .filter(entry => entry.key && entry.value)
381 | .map(entry => {
382 | const key = entry.key.padStart(3, '0');
383 | const length = entry.value.length.toString().padStart(2, '0');
384 | return key + length + entry.value;
385 | })
386 | .join('');
387 | }
388 | };
389 |
390 | export default KLVParser;
--------------------------------------------------------------------------------
/src/tests/components/KLVBuilder.test.tsx:
--------------------------------------------------------------------------------
1 | import { vi } from 'vitest';
2 | import { render, screen, within } from '@testing-library/react';
3 | import userEvent from '@testing-library/user-event';
4 | import KLVBuilder from '../../components/KLVBuilder';
5 |
6 | describe('KLVBuilder', () => {
7 | const mockOnBuild = vi.fn();
8 |
9 | beforeEach(() => {
10 | mockOnBuild.mockClear();
11 | });
12 |
13 | describe('Initial Rendering', () => {
14 | it('should render KLV Builder with initial state', () => {
15 | render();
16 |
17 | expect(screen.getByText('KLV Builder')).toBeInTheDocument();
18 | expect(screen.getByText('Clear All')).toBeInTheDocument();
19 | expect(screen.getByText('Add Entry')).toBeInTheDocument();
20 | expect(screen.getByText('Build KLV String')).toBeInTheDocument();
21 |
22 | // Should have one initial entry
23 | expect(screen.getByText('Key')).toBeInTheDocument();
24 | expect(screen.getByPlaceholderText('Enter hex value...')).toBeInTheDocument();
25 | });
26 |
27 | it('should have correct initial entry values', () => {
28 | render();
29 |
30 | const keySelect = screen.getByDisplayValue(/^002 - /);
31 | expect(keySelect).toBeInTheDocument();
32 |
33 | const valueInput = screen.getByPlaceholderText('Enter hex value...');
34 | expect(valueInput).toHaveValue('');
35 | });
36 |
37 | it('should show length of 0 for empty value', () => {
38 | render();
39 |
40 | expect(screen.getByText((content, element) => {
41 | return element?.tagName === 'LABEL' && element?.textContent?.includes('Length:') && element?.textContent?.includes('0') || false;
42 | })).toBeInTheDocument();
43 | });
44 |
45 | it('should have Build KLV button disabled initially', () => {
46 | render();
47 |
48 | const buildButton = screen.getByText('Build KLV String');
49 | expect(buildButton).toBeDisabled();
50 | });
51 |
52 | it('should not show preview initially', () => {
53 | render();
54 |
55 | expect(screen.queryByText('KLV String Preview')).not.toBeInTheDocument();
56 | });
57 | });
58 |
59 | describe('Adding Entries', () => {
60 | it('should add new entry when Add Entry button is clicked', async () => {
61 | const user = userEvent.setup();
62 | render();
63 |
64 | const addButton = screen.getByText('Add Entry');
65 | await user.click(addButton);
66 |
67 | // Should have 2 entries now
68 | const keyLabels = screen.getAllByText('Key');
69 | expect(keyLabels).toHaveLength(2);
70 |
71 | const valueInputs = screen.getAllByPlaceholderText('Enter hex value...');
72 | expect(valueInputs).toHaveLength(2);
73 | });
74 |
75 | it('should add multiple entries', async () => {
76 | const user = userEvent.setup();
77 | render();
78 |
79 | const addButton = screen.getByText('Add Entry');
80 | await user.click(addButton);
81 | await user.click(addButton);
82 | await user.click(addButton);
83 |
84 | const keyLabels = screen.getAllByText('Key');
85 | expect(keyLabels).toHaveLength(4);
86 | });
87 |
88 | it('should add new entry with next available key', async () => {
89 | const user = userEvent.setup();
90 | render();
91 |
92 | const addButton = screen.getByText('Add Entry');
93 | await user.click(addButton);
94 |
95 | // Second entry should have a different key since 002 is already used
96 | const keyLabels = screen.getAllByText('Key');
97 | expect(keyLabels).toHaveLength(2);
98 | });
99 | });
100 |
101 | describe('Removing Entries', () => {
102 | it('should not allow removing the last entry', () => {
103 | render();
104 |
105 | const removeButton = screen.getByTitle('Cannot remove the last entry');
106 | expect(removeButton).toBeDisabled();
107 | });
108 |
109 | it('should allow removing entry when there are multiple entries', async () => {
110 | const user = userEvent.setup();
111 | render();
112 |
113 | // Add an entry first
114 | const addButton = screen.getByText('Add Entry');
115 | await user.click(addButton);
116 |
117 | // Now remove buttons should be enabled (title changes when not disabled)
118 | const removeButtons = screen.getAllByRole('button').filter(btn =>
119 | btn.querySelector('svg') && btn.className.includes('text-red-500')
120 | );
121 | expect(removeButtons.length).toBeGreaterThanOrEqual(2);
122 | removeButtons.forEach(button => {
123 | expect(button).not.toBeDisabled();
124 | });
125 | });
126 |
127 | it('should remove correct entry when remove button is clicked', async () => {
128 | const user = userEvent.setup();
129 | render();
130 |
131 | // Add entries and set different values
132 | await user.click(screen.getByText('Add Entry'));
133 |
134 | const valueInputs = screen.getAllByPlaceholderText('Enter hex value...');
135 | await user.type(valueInputs[0], 'First');
136 | await user.type(valueInputs[1], 'Second');
137 |
138 | // Remove first entry
139 | const removeButtons = screen.getAllByRole('button').filter(btn =>
140 | btn.querySelector('svg') && btn.className.includes('text-red-500')
141 | );
142 | await user.click(removeButtons[0]);
143 |
144 | // Should have only one entry with 'Second' value
145 | const remainingInput = screen.getByPlaceholderText('Enter hex value...');
146 | expect(remainingInput).toHaveValue('Second');
147 | });
148 | });
149 |
150 | describe('Updating Entries', () => {
151 | it('should update entry key when select value changes', async () => {
152 | const user = userEvent.setup();
153 | render();
154 |
155 | const keySelect = screen.getByDisplayValue(/^002 - /);
156 | await user.selectOptions(keySelect, '026');
157 |
158 | // Check if the select now has the 026 value
159 | expect(keySelect).toHaveValue('026');
160 | });
161 |
162 | it('should update entry value when input changes', async () => {
163 | const user = userEvent.setup();
164 | render();
165 |
166 | const valueInput = screen.getByPlaceholderText('Enter hex value...');
167 | await user.type(valueInput, 'TEST123');
168 |
169 | expect(valueInput).toHaveValue('TEST123');
170 | });
171 |
172 | it('should update length display when value changes', async () => {
173 | const user = userEvent.setup();
174 | render();
175 |
176 | const valueInput = screen.getByPlaceholderText('Enter hex value...');
177 | await user.type(valueInput, 'TEST');
178 |
179 | expect(screen.getByText((content, element) => {
180 | return element?.tagName === 'LABEL' && element?.textContent?.includes('Length:') && element?.textContent?.includes('4') || false;
181 | })).toBeInTheDocument();
182 | });
183 |
184 | it('should enable Build KLV button when value is entered', async () => {
185 | const user = userEvent.setup();
186 | render();
187 |
188 | const valueInput = screen.getByPlaceholderText('Enter hex value...');
189 | await user.type(valueInput, 'TEST');
190 |
191 | const buildButton = screen.getByText('Build KLV String');
192 | expect(buildButton).not.toBeDisabled();
193 | });
194 | });
195 |
196 | describe('Clear All Functionality', () => {
197 | it('should reset to single empty entry when Clear All is clicked', async () => {
198 | const user = userEvent.setup();
199 | render();
200 |
201 | // Add entries and values
202 | await user.click(screen.getByText('Add Entry'));
203 | const valueInputs = screen.getAllByPlaceholderText('Enter hex value...');
204 | await user.type(valueInputs[0], 'First');
205 | await user.type(valueInputs[1], 'Second');
206 |
207 | // Clear all
208 | await user.click(screen.getByText('Clear All'));
209 |
210 | // Should have only one empty entry
211 | const keyLabels = screen.getAllByText('Key');
212 | expect(keyLabels).toHaveLength(1);
213 |
214 | const valueInput = screen.getByPlaceholderText('Enter hex value...');
215 | expect(valueInput).toHaveValue('');
216 |
217 | expect(screen.getByDisplayValue(/^002 - /)).toBeInTheDocument();
218 | });
219 | });
220 |
221 | describe('Building KLV', () => {
222 | it('should call onBuild with correct KLV string when Build KLV is clicked', async () => {
223 | const user = userEvent.setup();
224 | render();
225 |
226 | const valueInput = screen.getByPlaceholderText('Enter hex value...');
227 | await user.type(valueInput, 'TEST');
228 |
229 | const buildButton = screen.getByText('Build KLV String');
230 | await user.click(buildButton);
231 |
232 | expect(mockOnBuild).toHaveBeenCalledWith('00204TEST');
233 | });
234 |
235 | it('should not call onBuild when KLV string is empty', async () => {
236 | const user = userEvent.setup();
237 | render();
238 |
239 | // Build KLV without entering any values (button should be disabled)
240 | const buildButton = screen.getByText('Build KLV String');
241 | expect(buildButton).toBeDisabled();
242 |
243 | expect(mockOnBuild).not.toHaveBeenCalled();
244 | });
245 |
246 | it('should build KLV with multiple entries', async () => {
247 | const user = userEvent.setup();
248 | render();
249 |
250 | // Add second entry
251 | await user.click(screen.getByText('Add Entry'));
252 |
253 | // Set values for both entries
254 | const valueInputs = screen.getAllByPlaceholderText('Enter hex value...');
255 | await user.type(valueInputs[0], 'ABC');
256 | await user.type(valueInputs[1], 'XYZ');
257 |
258 | // Change second entry key
259 | const keySelects = screen.getAllByRole('combobox');
260 | await user.selectOptions(keySelects[1], '026');
261 |
262 | const buildButton = screen.getByText('Build KLV String');
263 | await user.click(buildButton);
264 |
265 | expect(mockOnBuild).toHaveBeenCalledWith('00203ABC02603XYZ');
266 | });
267 | });
268 |
269 | describe('Preview Functionality', () => {
270 | it('should show preview when entry has value', async () => {
271 | const user = userEvent.setup();
272 | render();
273 |
274 | const valueInput = screen.getByPlaceholderText('Enter hex value...');
275 | await user.type(valueInput, 'TEST');
276 |
277 | expect(screen.getByText('KLV String Preview')).toBeInTheDocument();
278 | expect(screen.getByText('00204TEST')).toBeInTheDocument();
279 | });
280 |
281 | it('should update preview when values change', async () => {
282 | const user = userEvent.setup();
283 | render();
284 |
285 | const valueInput = screen.getByPlaceholderText('Enter hex value...');
286 | await user.type(valueInput, 'ABC');
287 |
288 | expect(screen.getByText('00203ABC')).toBeInTheDocument();
289 |
290 | await user.clear(valueInput);
291 | await user.type(valueInput, 'DEFGH');
292 |
293 | expect(screen.getByText('00205DEFGH')).toBeInTheDocument();
294 | });
295 |
296 | it('should hide preview when all values are empty', async () => {
297 | const user = userEvent.setup();
298 | render();
299 |
300 | const valueInput = screen.getByPlaceholderText('Enter hex value...');
301 | await user.type(valueInput, 'TEST');
302 |
303 | expect(screen.getByText('KLV String Preview')).toBeInTheDocument();
304 |
305 | await user.clear(valueInput);
306 |
307 | expect(screen.queryByText('KLV String Preview')).not.toBeInTheDocument();
308 | });
309 | });
310 |
311 | describe('Key Selection', () => {
312 | it('should show all available KLV definitions in key select', () => {
313 | render();
314 |
315 | const keySelect = screen.getByDisplayValue(/^002 - /);
316 | const options = within(keySelect).getAllByRole('option');
317 |
318 | // Should have options for all defined keys (100+ keys)
319 | expect(options.length).toBeGreaterThan(100);
320 |
321 | // Check some specific keys that we know exist
322 | expect(within(keySelect).getByText(/^002 - /)).toBeInTheDocument();
323 | expect(within(keySelect).getByText(/^042 - /)).toBeInTheDocument();
324 | });
325 |
326 | it('should show full key names in options', () => {
327 | render();
328 |
329 | const keySelect = screen.getByDisplayValue(/^002 - /);
330 |
331 | // Options should show full key names (not truncated)
332 | const options = within(keySelect).getAllByRole('option');
333 | expect(options.length).toBeGreaterThan(0);
334 |
335 | // Check that options have the expected format
336 | const firstOption = options[0];
337 | expect(firstOption.textContent).toMatch(/^\d{3} - .+$/);
338 | });
339 | });
340 |
341 | describe('Styling and Accessibility', () => {
342 | it('should have correct CSS classes for layout', () => {
343 | render();
344 |
345 | // The main container div has space-y-4 class
346 | const container = screen.getByText('KLV Builder').parentElement?.parentElement?.parentElement;
347 | expect(container).toHaveClass('space-y-4');
348 | });
349 |
350 | it('should have proper labels for form elements', () => {
351 | render();
352 |
353 | expect(screen.getByText('Key')).toBeInTheDocument();
354 | expect(screen.getByText((content, element) => {
355 | return element?.tagName === 'LABEL' && element?.textContent?.includes('Value') && element?.textContent?.includes('Length:') || false;
356 | })).toBeInTheDocument();
357 | });
358 |
359 | it('should have proper button styling', () => {
360 | render();
361 |
362 | const addButton = screen.getByText('Add Entry');
363 | expect(addButton).toHaveClass('bg-blue-500', 'text-white');
364 |
365 | const buildButton = screen.getByText('Build KLV String');
366 | expect(buildButton).toHaveClass('bg-green-500', 'text-white');
367 |
368 | const clearButton = screen.getByText('Clear All');
369 | expect(clearButton).toHaveClass('text-red-600', 'border-red-300');
370 | });
371 |
372 | it('should have proper disabled state styling', () => {
373 | render();
374 |
375 | const buildButton = screen.getByText('Build KLV String');
376 | expect(buildButton).toHaveClass('disabled:opacity-50', 'disabled:cursor-not-allowed');
377 |
378 | const removeButton = screen.getByTitle('Cannot remove the last entry');
379 | expect(removeButton).toHaveClass('disabled:opacity-30', 'disabled:cursor-not-allowed');
380 | });
381 | });
382 |
383 | describe('Edge Cases', () => {
384 | it('should handle very long values', async () => {
385 | const user = userEvent.setup();
386 | render();
387 |
388 | const longValue = 'A'.repeat(50);
389 | const valueInput = screen.getByPlaceholderText('Enter hex value...');
390 | await user.type(valueInput, longValue);
391 |
392 | expect(screen.getByText((content, element) => {
393 | return element?.tagName === 'LABEL' && element?.textContent?.includes('Length:') && element?.textContent?.includes('50') || false;
394 | })).toBeInTheDocument();
395 | expect(screen.getByText('KLV String Preview')).toBeInTheDocument();
396 | });
397 |
398 | it('should handle special characters in values', async () => {
399 | const user = userEvent.setup();
400 | render();
401 |
402 | const specialValue = 'Test@#$%^&*()';
403 | const valueInput = screen.getByPlaceholderText('Enter hex value...');
404 | await user.type(valueInput, specialValue);
405 |
406 | expect(valueInput).toHaveValue(specialValue);
407 | expect(screen.getByText((content, element) => {
408 | return element?.tagName === 'LABEL' && element?.textContent?.includes('Length:') && element?.textContent?.includes(specialValue.length.toString()) || false;
409 | })).toBeInTheDocument();
410 | });
411 |
412 | it('should handle rapid key changes', async () => {
413 | const user = userEvent.setup();
414 | render();
415 |
416 | const keySelect = screen.getByDisplayValue(/^002 - /);
417 |
418 | await user.selectOptions(keySelect, '026');
419 | await user.selectOptions(keySelect, '042');
420 | await user.selectOptions(keySelect, '999');
421 |
422 | expect(screen.getByDisplayValue(/^999 - /)).toBeInTheDocument();
423 | });
424 | });
425 | });
--------------------------------------------------------------------------------
/src/tests/components/BatchProcessor.test.tsx:
--------------------------------------------------------------------------------
1 | import { vi } from 'vitest';
2 | import { render, screen, waitFor, fireEvent } from '@testing-library/react';
3 | import userEvent from '@testing-library/user-event';
4 | import BatchProcessor from '../../components/BatchProcessor';
5 |
6 | describe('BatchProcessor', () => {
7 | const mockOnProcess = vi.fn();
8 |
9 | beforeEach(() => {
10 | mockOnProcess.mockClear();
11 | });
12 |
13 | afterEach(() => {
14 | vi.useRealTimers();
15 | });
16 |
17 | describe('Initial Rendering', () => {
18 | it('should render batch processor with initial state', () => {
19 | render();
20 |
21 | expect(screen.getByText('Batch Processor')).toBeInTheDocument();
22 | expect(screen.getByText('Load Sample')).toBeInTheDocument();
23 | expect(screen.getByText('Clear')).toBeInTheDocument();
24 | expect(screen.getByText('KLV Data (one entry per line)')).toBeInTheDocument();
25 | expect(screen.getByText('Process Batch')).toBeInTheDocument();
26 | });
27 |
28 | it('should have correct initial textarea state', () => {
29 | render();
30 |
31 | const textarea = screen.getByPlaceholderText(/Enter multiple KLV strings/);
32 | expect(textarea).toHaveValue('');
33 | expect(screen.getByText('Lines to process: 0')).toBeInTheDocument();
34 | });
35 |
36 | it('should have Process Batch button disabled initially', () => {
37 | render();
38 |
39 | const processButton = screen.getByText('Process Batch');
40 | expect(processButton).toBeDisabled();
41 | });
42 |
43 | it('should not show results initially', () => {
44 | render();
45 |
46 | expect(screen.queryByText('Batch Results')).not.toBeInTheDocument();
47 | });
48 | });
49 |
50 | describe('Sample Data Loading', () => {
51 | it('should load sample data when Load Sample button is clicked', async () => {
52 | const user = userEvent.setup();
53 | render();
54 |
55 | const loadSampleButton = screen.getByText('Load Sample');
56 | await user.click(loadSampleButton);
57 |
58 | const textarea = screen.getByPlaceholderText(/Enter multiple KLV strings/);
59 | const textareaValue = (textarea as HTMLTextAreaElement).value;
60 | expect(textareaValue).toContain('00206AB48DE026044577');
61 | expect(textareaValue).toContain('04210000050010008USD04305Test Merchant');
62 | expect(screen.getByText('Lines to process: 4')).toBeInTheDocument();
63 | });
64 |
65 | it('should enable Process Batch button after loading sample', async () => {
66 | const user = userEvent.setup();
67 | render();
68 |
69 | const loadSampleButton = screen.getByText('Load Sample');
70 | await user.click(loadSampleButton);
71 |
72 | const processButton = screen.getByText('Process Batch');
73 | expect(processButton).not.toBeDisabled();
74 | });
75 | });
76 |
77 | describe('Clear Functionality', () => {
78 | it('should clear textarea and results when Clear button is clicked', async () => {
79 | const user = userEvent.setup();
80 | render();
81 |
82 | // Load sample data first
83 | await user.click(screen.getByText('Load Sample'));
84 |
85 | // Clear the data
86 | const clearButton = screen.getByText('Clear');
87 | await user.click(clearButton);
88 |
89 | const textarea = screen.getByPlaceholderText(/Enter multiple KLV strings/);
90 | expect(textarea).toHaveValue('');
91 | expect(screen.getByText('Lines to process: 0')).toBeInTheDocument();
92 | });
93 | });
94 |
95 | describe('Input Management', () => {
96 | it('should update textarea value and line count when user types', async () => {
97 | const user = userEvent.setup();
98 | render();
99 |
100 | const textarea = screen.getByPlaceholderText(/Enter multiple KLV strings/);
101 | await user.type(textarea, '00206AB48DE026044577\n04210000050010008USD');
102 |
103 | expect(textarea).toHaveValue('00206AB48DE026044577\n04210000050010008USD');
104 | expect(screen.getByText('Lines to process: 2')).toBeInTheDocument();
105 | });
106 |
107 | it('should not count empty lines in line count', async () => {
108 | const user = userEvent.setup();
109 | render();
110 |
111 | const textarea = screen.getByPlaceholderText(/Enter multiple KLV strings/);
112 | await user.type(textarea, '00206AB48DE026044577\n\n\n04210000050010008USD\n');
113 |
114 | expect(screen.getByText('Lines to process: 2')).toBeInTheDocument();
115 | });
116 |
117 | it('should enable Process Batch button when input is provided', async () => {
118 | const user = userEvent.setup();
119 | render();
120 |
121 | const textarea = screen.getByPlaceholderText(/Enter multiple KLV strings/);
122 | await user.type(textarea, '00206AB48DE026044577');
123 |
124 | const processButton = screen.getByText('Process Batch');
125 | expect(processButton).not.toBeDisabled();
126 | });
127 | });
128 |
129 | describe('Batch Processing', () => {
130 | it('should show processing state when Process Batch is clicked', async () => {
131 | const user = userEvent.setup();
132 | render();
133 |
134 | const textarea = screen.getByPlaceholderText(/Enter multiple KLV strings/);
135 | await user.type(textarea, '00206AB48DE026044577');
136 |
137 | const processButton = screen.getByText('Process Batch');
138 | await user.click(processButton);
139 |
140 | expect(screen.getByText('Processing...')).toBeInTheDocument();
141 | expect(screen.getByRole('button', { name: /processing/i })).toBeDisabled();
142 | });
143 |
144 | it('should process single valid KLV string and show results', async () => {
145 | const user = userEvent.setup();
146 | render();
147 |
148 | const textarea = screen.getByPlaceholderText(/Enter multiple KLV strings/);
149 | await user.type(textarea, '00206AB48DE026044577');
150 |
151 | const processButton = screen.getByText('Process Batch');
152 | await user.click(processButton);
153 |
154 | // Wait for the processing delay (500ms) and state updates
155 | await waitFor(() => {
156 | expect(screen.getByText('Batch Results')).toBeInTheDocument();
157 | expect(screen.getByText(/1 successful,\s*0 failed/)).toBeInTheDocument();
158 | expect(screen.getByText('Line 1')).toBeInTheDocument();
159 | expect(screen.getByText('2 entries')).toBeInTheDocument();
160 | }, { timeout: 2000 });
161 |
162 | expect(mockOnProcess).toHaveBeenCalledWith([
163 | expect.objectContaining({
164 | line: 1,
165 | input: '00206AB48DE026044577',
166 | results: expect.arrayContaining([
167 | expect.objectContaining({ key: '002' }),
168 | expect.objectContaining({ key: '026' })
169 | ]),
170 | errors: []
171 | })
172 | ]);
173 | });
174 |
175 | it('should process multiple KLV strings and show aggregated results', async () => {
176 | const user = userEvent.setup();
177 | render();
178 |
179 | const textarea = screen.getByPlaceholderText(/Enter multiple KLV strings/);
180 | await user.clear(textarea);
181 | await user.type(textarea, '00206AB48DE026044577\n026044578');
182 |
183 | const processButton = screen.getByText('Process Batch');
184 | await user.click(processButton);
185 |
186 | await waitFor(() => {
187 | expect(screen.getByText('Batch Results')).toBeInTheDocument();
188 | expect(screen.getByText(/2 successful,\s*0 failed/)).toBeInTheDocument();
189 | expect(screen.getByText('Line 1')).toBeInTheDocument();
190 | expect(screen.getByText('Line 2')).toBeInTheDocument();
191 | }, { timeout: 2000 });
192 |
193 | expect(mockOnProcess).toHaveBeenCalledWith(
194 | expect.arrayContaining([
195 | expect.objectContaining({
196 | line: 1,
197 | input: '00206AB48DE026044577'
198 | }),
199 | expect.objectContaining({
200 | line: 2,
201 | input: '026044578'
202 | })
203 | ])
204 | );
205 | });
206 |
207 | it('should handle invalid KLV strings and show errors', async () => {
208 | const user = userEvent.setup();
209 | render();
210 |
211 | const textarea = screen.getByPlaceholderText(/Enter multiple KLV strings/);
212 | await user.type(textarea, 'INVALID_KLV_STRING');
213 |
214 | const processButton = screen.getByText('Process Batch');
215 | await user.click(processButton);
216 |
217 | await waitFor(() => {
218 | expect(screen.getByText('Batch Results')).toBeInTheDocument();
219 | expect(screen.getByText(/0 successful,\s*1 failed/)).toBeInTheDocument();
220 | expect(screen.getByText(/errors/)).toBeInTheDocument();
221 | }, { timeout: 2000 });
222 | });
223 |
224 | it('should handle mixed valid and invalid KLV strings', async () => {
225 | const user = userEvent.setup();
226 | render();
227 |
228 | const textarea = screen.getByPlaceholderText(/Enter multiple KLV strings/);
229 | await user.clear(textarea);
230 | await user.type(textarea, '00206AB48DE026044577\nINVALID_STRING\n026044578');
231 |
232 | const processButton = screen.getByText('Process Batch');
233 | await user.click(processButton);
234 |
235 | await waitFor(() => {
236 | expect(screen.getByText('Batch Results')).toBeInTheDocument();
237 | expect(screen.getByText(/2 successful.*1 failed/)).toBeInTheDocument();
238 | }, { timeout: 2000 });
239 | });
240 | });
241 |
242 | describe('Results Display', () => {
243 | it('should show correct success indicators for valid entries', async () => {
244 | const user = userEvent.setup();
245 | render();
246 |
247 | const textarea = screen.getByPlaceholderText(/Enter multiple KLV strings/);
248 | await user.type(textarea, '00206AB48DE026044577');
249 |
250 | await user.click(screen.getByText('Process Batch'));
251 |
252 | await waitFor(() => {
253 | const successResult = screen.getByText('Line 1').parentElement?.parentElement;
254 | expect(successResult).toHaveClass('bg-green-50', 'border-green-200');
255 | expect(screen.getByText('Keys found: 002, 026')).toBeInTheDocument();
256 | }, { timeout: 2000 });
257 | });
258 |
259 | it('should show correct error indicators for invalid entries', async () => {
260 | const user = userEvent.setup();
261 | render();
262 |
263 | const textarea = screen.getByPlaceholderText(/Enter multiple KLV strings/);
264 | await user.type(textarea, 'INVALID_KLV');
265 |
266 | await user.click(screen.getByText('Process Batch'));
267 |
268 | await waitFor(() => {
269 | const errorResult = screen.getByText('Line 1').parentElement?.parentElement;
270 | expect(errorResult).toHaveClass('bg-red-50', 'border-red-200');
271 | expect(screen.getByText(/Errors:/)).toBeInTheDocument();
272 | }, { timeout: 2000 });
273 | });
274 |
275 | it('should display original input for each result', async () => {
276 | const user = userEvent.setup();
277 | render();
278 |
279 | const inputString = '00206AB48DE026044577';
280 | const textarea = screen.getByPlaceholderText(/Enter multiple KLV strings/);
281 | await user.type(textarea, inputString);
282 |
283 | await user.click(screen.getByText('Process Batch'));
284 |
285 | await waitFor(() => {
286 | expect(screen.getByText(inputString)).toBeInTheDocument();
287 | }, { timeout: 2000 });
288 | });
289 |
290 | it('should handle results with scrollable area when many entries', async () => {
291 | const user = userEvent.setup();
292 | render();
293 |
294 | // Load sample data which has 4 entries
295 | await user.click(screen.getByText('Load Sample'));
296 | await user.click(screen.getByText('Process Batch'));
297 |
298 | await waitFor(() => {
299 | const resultsContainer = screen.getByText('Batch Results').parentElement?.parentElement?.querySelector('.max-h-96');
300 | expect(resultsContainer).toBeInTheDocument();
301 | expect(resultsContainer).toHaveClass('overflow-y-auto');
302 | }, { timeout: 2000 });
303 | });
304 | });
305 |
306 | describe('Styling and Accessibility', () => {
307 | it('should have correct CSS classes for layout', () => {
308 | render();
309 |
310 | // The root container has space-y-4, not the title container
311 | const container = screen.getByText('Batch Processor').closest('div')?.parentElement;
312 | expect(container).toHaveClass('space-y-4');
313 | });
314 |
315 | it('should have proper button styling', () => {
316 | render();
317 |
318 | const loadSampleButton = screen.getByText('Load Sample');
319 | expect(loadSampleButton).toHaveClass('bg-blue-100', 'text-blue-700');
320 |
321 | const processButton = screen.getByText('Process Batch');
322 | expect(processButton).toHaveClass('bg-purple-500', 'text-white');
323 |
324 | const clearButton = screen.getByText('Clear');
325 | expect(clearButton).toHaveClass('border', 'rounded');
326 | });
327 |
328 | it('should have proper textarea styling', () => {
329 | render();
330 |
331 | const textarea = screen.getByPlaceholderText(/Enter multiple KLV strings/);
332 | expect(textarea).toHaveClass('font-mono', 'text-sm', 'resize-vertical');
333 | expect(textarea).toHaveAttribute('rows', '8');
334 | });
335 |
336 | it('should have proper disabled state styling', () => {
337 | render();
338 |
339 | const processButton = screen.getByText('Process Batch');
340 | expect(processButton).toHaveClass('disabled:opacity-50', 'disabled:cursor-not-allowed');
341 | });
342 |
343 | it('should have proper labels for form elements', () => {
344 | render();
345 |
346 | expect(screen.getByText('KLV Data (one entry per line)')).toBeInTheDocument();
347 | });
348 | });
349 |
350 | describe('Edge Cases', () => {
351 | it('should handle empty lines and whitespace correctly', async () => {
352 | const user = userEvent.setup();
353 | render();
354 |
355 | const textarea = screen.getByPlaceholderText(/Enter multiple KLV strings/);
356 | await user.type(textarea, '00206AB48DE026044577\n\n \n\t\n04210000050010008USD');
357 |
358 | await user.click(screen.getByText('Process Batch'));
359 |
360 | await waitFor(() => {
361 | expect(mockOnProcess).toHaveBeenCalledWith([
362 | expect.objectContaining({ line: 1, input: '00206AB48DE026044577' }),
363 | expect.objectContaining({ line: 2, input: '04210000050010008USD' })
364 | ]);
365 | });
366 | });
367 |
368 | it('should trim whitespace from input lines', async () => {
369 | // removed fake timers
370 | const user = userEvent.setup();
371 | render();
372 |
373 | const textarea = screen.getByPlaceholderText(/Enter multiple KLV strings/);
374 | await user.type(textarea, ' 00206AB48DE026044577 ');
375 |
376 | await user.click(screen.getByText('Process Batch'));
377 | // removed timer advancement
378 |
379 | await waitFor(() => {
380 | expect(mockOnProcess).toHaveBeenCalledWith([
381 | expect.objectContaining({ input: '00206AB48DE026044577' })
382 | ]);
383 | });
384 | });
385 |
386 | it('should handle very long input strings', async () => {
387 | // removed fake timers
388 | const user = userEvent.setup();
389 | render();
390 |
391 | const longInput = '00206AB48DE026044577'.repeat(10);
392 | const textarea = screen.getByPlaceholderText(/Enter multiple KLV strings/);
393 | await user.type(textarea, longInput);
394 |
395 | await user.click(screen.getByText('Process Batch'));
396 | // removed timer advancement
397 |
398 | await waitFor(() => {
399 | expect(mockOnProcess).toHaveBeenCalledWith([
400 | expect.objectContaining({ input: longInput })
401 | ]);
402 | });
403 | });
404 |
405 | it('should handle special characters in input', async () => {
406 | // removed fake timers
407 | const user = userEvent.setup();
408 | render();
409 |
410 | const specialInput = 'ABC@#$%^&*()DEF';
411 | const textarea = screen.getByPlaceholderText(/Enter multiple KLV strings/);
412 | await user.type(textarea, specialInput);
413 |
414 | const processButton = screen.getByRole('button', { name: /process batch/i });
415 | await user.click(processButton);
416 | // removed timer advancement
417 |
418 | await waitFor(() => {
419 | expect(mockOnProcess).toHaveBeenCalledWith([
420 | expect.objectContaining({ input: specialInput })
421 | ]);
422 | });
423 | });
424 | });
425 |
426 | describe('Component Lifecycle', () => {
427 | it('should clear results when new processing starts', async () => {
428 | // removed fake timers
429 | const user = userEvent.setup();
430 | render();
431 |
432 | // Process first batch
433 | const textarea = screen.getByPlaceholderText(/Enter multiple KLV strings/);
434 | await user.type(textarea, '00206AB48DE026044577');
435 | const processButton = screen.getByRole('button', { name: /process batch/i });
436 | await user.click(processButton);
437 | // removed timer advancement
438 |
439 | await waitFor(() => {
440 | expect(screen.getByText('Batch Results')).toBeInTheDocument();
441 | });
442 |
443 | // Process second batch
444 | await user.clear(textarea);
445 | await user.type(textarea, '04210000050010008USD');
446 | await user.click(processButton);
447 | // removed timer advancement
448 |
449 | await waitFor(() => {
450 | expect(mockOnProcess).toHaveBeenCalledTimes(2);
451 | });
452 | });
453 |
454 | it('should maintain state consistency during rapid interactions', async () => {
455 | const user = userEvent.setup();
456 | render();
457 |
458 | // Rapid sequence of actions
459 | const loadSampleButton = screen.getByText('Load Sample');
460 | const clearButton = screen.getByText('Clear');
461 |
462 | await user.click(loadSampleButton);
463 | await user.click(clearButton);
464 | await user.click(loadSampleButton);
465 |
466 | const textarea = screen.getByPlaceholderText(/Enter multiple KLV strings/);
467 | const textareaValue = (textarea as HTMLTextAreaElement).value;
468 | expect(textareaValue).toContain('00206AB48DE026044577');
469 | });
470 | });
471 | });
--------------------------------------------------------------------------------
/src/tests/components/App.test.tsx:
--------------------------------------------------------------------------------
1 | import { vi } from 'vitest';
2 | import { render, screen, waitFor, fireEvent } from '@testing-library/react';
3 | import userEvent from '@testing-library/user-event';
4 | import App from '../../App';
5 | import { mockClipboardAPI } from '../helpers/testUtils';
6 |
7 | // Mock clipboard API
8 | const mockWriteText = mockClipboardAPI();
9 |
10 | describe('App Integration Tests', () => {
11 | beforeEach(() => {
12 | mockWriteText.mockClear();
13 | vi.clearAllMocks();
14 | });
15 |
16 | describe('Initial Rendering', () => {
17 | it('should render the main app with header and navigation', () => {
18 | render();
19 |
20 | expect(screen.getByText('KLV Data Extraction Suite')).toBeInTheDocument();
21 | expect(screen.getByText('Complete toolkit for KLV data processing, parsing, and analysis')).toBeInTheDocument();
22 |
23 | // Check navigation tabs
24 | expect(screen.getByText('Extractor')).toBeInTheDocument();
25 | expect(screen.getByText('Builder')).toBeInTheDocument();
26 | expect(screen.getByText('Batch')).toBeInTheDocument();
27 | expect(screen.getByText('History')).toBeInTheDocument();
28 | });
29 |
30 | it('should start with extractor tab active', () => {
31 | render();
32 |
33 | const extractorTab = screen.getByRole('button', { name: /extractor/i });
34 | expect(extractorTab).toHaveClass('border-blue-500', 'text-blue-600');
35 |
36 | // Should show the KLV input textarea
37 | expect(screen.getByPlaceholderText(/Enter KLV data/)).toBeInTheDocument();
38 | });
39 |
40 | it('should show default sample KLV data', () => {
41 | render();
42 |
43 | const textarea = screen.getByPlaceholderText(/Enter KLV data/);
44 | expect(textarea).toHaveValue('00206AB48DE026044577');
45 | });
46 |
47 | it('should render quick reference footer', () => {
48 | render();
49 |
50 | expect(screen.getByText('KLV Format Quick Reference')).toBeInTheDocument();
51 | expect(screen.getByText('Format Structure:')).toBeInTheDocument();
52 | expect(screen.getByText('Example:')).toBeInTheDocument();
53 | });
54 | });
55 |
56 | describe('Tab Navigation', () => {
57 | it('should switch to builder tab when clicked', async () => {
58 | const user = userEvent.setup();
59 | render();
60 |
61 | const builderTab = screen.getByRole('button', { name: /builder/i });
62 | await user.click(builderTab);
63 |
64 | expect(builderTab).toHaveClass('border-blue-500', 'text-blue-600');
65 | expect(screen.getByText('KLV Builder')).toBeInTheDocument();
66 | });
67 |
68 | it('should switch to batch tab when clicked', async () => {
69 | const user = userEvent.setup();
70 | render();
71 |
72 | const batchTab = screen.getByRole('button', { name: /batch/i });
73 | await user.click(batchTab);
74 |
75 | expect(batchTab).toHaveClass('border-blue-500', 'text-blue-600');
76 | expect(screen.getByText('Batch Processor')).toBeInTheDocument();
77 | });
78 |
79 | it('should switch to history tab when clicked', async () => {
80 | const user = userEvent.setup();
81 | render();
82 |
83 | // Find the tab by looking for the button with History that has an svg icon (tab navigation)
84 | const historyTab = screen.getAllByRole('button', { name: /history/i })
85 | .find(button => button.querySelector('svg'));
86 | await user.click(historyTab!);
87 |
88 | expect(historyTab).toHaveClass('border-blue-500', 'text-blue-600');
89 | expect(screen.getByText('Processing History')).toBeInTheDocument();
90 | expect(screen.getByText('No processing history yet')).toBeInTheDocument();
91 | });
92 |
93 | it('should maintain tab state during navigation', async () => {
94 | const user = userEvent.setup();
95 | render();
96 |
97 | // Switch to builder
98 | await user.click(screen.getByRole('button', { name: /builder/i }));
99 | expect(screen.getByText('KLV Builder')).toBeInTheDocument();
100 |
101 | // Switch back to extractor
102 | await user.click(screen.getByRole('button', { name: /extractor/i }));
103 | expect(screen.getByPlaceholderText(/Enter KLV data/)).toBeInTheDocument();
104 | });
105 | });
106 |
107 | describe('KLV Parsing Integration', () => {
108 | it('should parse default KLV data and show results', () => {
109 | render();
110 |
111 | // Default data should parse and show results
112 | expect(screen.getByText('Parsed KLV Data (2 entries)')).toBeInTheDocument();
113 | expect(screen.getByText('Key 002')).toBeInTheDocument();
114 | expect(screen.getByText('Key 026')).toBeInTheDocument();
115 | expect(screen.getByText('AB48DE')).toBeInTheDocument();
116 | expect(screen.getByText('4577')).toBeInTheDocument();
117 | });
118 |
119 | it('should update results when KLV input changes', async () => {
120 | const user = userEvent.setup();
121 | render();
122 |
123 | const textarea = screen.getByPlaceholderText(/Enter KLV data/);
124 | await user.clear(textarea);
125 | await user.type(textarea, '00206AB48DE026044577042044TEST');
126 |
127 | await waitFor(() => {
128 | expect(screen.getByText('Parsed KLV Data (3 entries)')).toBeInTheDocument();
129 | expect(screen.getByText('Key 002')).toBeInTheDocument();
130 | expect(screen.getByText('Key 026')).toBeInTheDocument();
131 | expect(screen.getByText('Key 042')).toBeInTheDocument();
132 | });
133 | });
134 |
135 | it('should show statistics for parsed data', () => {
136 | render();
137 |
138 | expect(screen.getByText('Total Entries')).toBeInTheDocument();
139 | expect(screen.getAllByText('2').length).toBeGreaterThan(0);
140 | expect(screen.getByText('Total Length')).toBeInTheDocument();
141 | expect(screen.getByText('10')).toBeInTheDocument();
142 | });
143 |
144 | it('should handle invalid KLV data with error display', async () => {
145 | const user = userEvent.setup();
146 | render();
147 |
148 | const textarea = screen.getByPlaceholderText(/Enter KLV data/);
149 | await user.clear(textarea);
150 | await user.type(textarea, 'INVALID_KLV_DATA');
151 |
152 | await waitFor(() => {
153 | expect(screen.getByText('Parsing Errors')).toBeInTheDocument();
154 | });
155 | });
156 | });
157 |
158 | describe('Search and Filter Functionality', () => {
159 | it('should filter results based on search term', async () => {
160 | const user = userEvent.setup();
161 | render();
162 |
163 | // Search for key 002
164 | const searchInput = screen.getByPlaceholderText('Search keys, values, names...');
165 | await user.type(searchInput, '002');
166 |
167 | expect(screen.getByText('Key 002')).toBeInTheDocument();
168 | expect(screen.queryByText('Key 026')).not.toBeInTheDocument();
169 | });
170 |
171 | it('should show no results message for invalid search', async () => {
172 | const user = userEvent.setup();
173 | render();
174 |
175 | const searchInput = screen.getByPlaceholderText('Search keys, values, names...');
176 | await user.type(searchInput, 'NONEXISTENT');
177 |
178 | expect(screen.getByText('No entries match "NONEXISTENT"')).toBeInTheDocument();
179 | expect(screen.getByText('Clear search')).toBeInTheDocument();
180 | });
181 |
182 | it('should clear search when clear search button is clicked', async () => {
183 | const user = userEvent.setup();
184 | render();
185 |
186 | const searchInput = screen.getByPlaceholderText('Search keys, values, names...');
187 | await user.type(searchInput, 'NONEXISTENT');
188 |
189 | const clearButton = screen.getByText('Clear search');
190 | await user.click(clearButton);
191 |
192 | expect(searchInput).toHaveValue('');
193 | expect(screen.getByText('Key 002')).toBeInTheDocument();
194 | expect(screen.getByText('Key 026')).toBeInTheDocument();
195 | });
196 | });
197 |
198 | describe('Raw Data Toggle', () => {
199 | it('should show raw data when toggle is clicked', async () => {
200 | const user = userEvent.setup();
201 | render();
202 |
203 | const showRawButton = screen.getByText('Show Raw');
204 | await user.click(showRawButton);
205 |
206 | expect(screen.getByText('Hide Raw')).toBeInTheDocument();
207 | expect(screen.getByText('Raw KLV:')).toBeInTheDocument();
208 |
209 | // Check that the raw data section contains the KLV string
210 | const rawKLVElement = screen.getByText('Raw KLV:').parentElement;
211 | expect(rawKLVElement).toHaveTextContent('00206AB48DE026044577');
212 | });
213 |
214 | it('should hide raw data when toggle is clicked again', async () => {
215 | const user = userEvent.setup();
216 | render();
217 |
218 | // Show raw data first
219 | await user.click(screen.getByText('Show Raw'));
220 | expect(screen.getByText('Raw KLV:')).toBeInTheDocument();
221 |
222 | // Hide raw data
223 | await user.click(screen.getByText('Hide Raw'));
224 | expect(screen.queryByText('Raw KLV:')).not.toBeInTheDocument();
225 | expect(screen.getByText('Show Raw')).toBeInTheDocument();
226 | });
227 | });
228 |
229 | describe('Sample Data Loading', () => {
230 | it('should load sample data when sample buttons are clicked', async () => {
231 | const user = userEvent.setup();
232 | render();
233 |
234 | const sample2Button = screen.getByText('Sample 2');
235 | await user.click(sample2Button);
236 |
237 | const textarea = screen.getByPlaceholderText(/Enter KLV data/);
238 | expect(textarea).toHaveValue('04210000050010008USD04305Test Merchant25103EMV25107Visa');
239 | });
240 |
241 | it('should clear input when clear button is clicked', async () => {
242 | const user = userEvent.setup();
243 | render();
244 |
245 | const clearButton = screen.getByRole('button', { name: /clear/i });
246 | await user.click(clearButton);
247 |
248 | const textarea = screen.getByPlaceholderText(/Enter KLV data/);
249 | expect(textarea).toHaveValue('');
250 | });
251 | });
252 |
253 | describe('Copy to Clipboard Functionality', () => {
254 | it.skip('should copy individual values when copy button is clicked', async () => {
255 | const user = userEvent.setup();
256 | render();
257 |
258 | // Wait for parsing to complete first
259 | await waitFor(() => {
260 | expect(screen.getByText('Save to History')).toBeInTheDocument();
261 | });
262 |
263 | const copyButtons = screen.getAllByTitle('Copy value');
264 | expect(copyButtons.length).toBeGreaterThan(0);
265 |
266 | await user.click(copyButtons[0]);
267 |
268 | // Wait for async clipboard operation
269 | await waitFor(() => {
270 | expect(mockWriteText).toHaveBeenCalled();
271 | });
272 |
273 | expect(mockWriteText).toHaveBeenCalledWith('AB48DE');
274 | });
275 |
276 | it.skip('should handle clipboard errors gracefully', async () => {
277 | const user = userEvent.setup();
278 | mockWriteText.mockRejectedValue(new Error('Clipboard error'));
279 | const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
280 |
281 | render();
282 |
283 | const copyButtons = screen.getAllByTitle('Copy value');
284 | await user.click(copyButtons[0]);
285 |
286 | expect(consoleSpy).toHaveBeenCalledWith('Failed to copy:', expect.any(Error));
287 |
288 | consoleSpy.mockRestore();
289 | });
290 | });
291 |
292 | describe('History Management', () => {
293 | it('should add entry to history when Save to History is clicked', async () => {
294 | const user = userEvent.setup();
295 | render();
296 |
297 | // Wait for parsing to complete and Save to History button to appear
298 | await waitFor(() => {
299 | expect(screen.getByText('Save to History')).toBeInTheDocument();
300 | });
301 |
302 | const saveToHistoryButton = screen.getByText('Save to History');
303 | await user.click(saveToHistoryButton);
304 |
305 | // Switch to history tab - find the navigation tab specifically
306 | const historyTab = screen.getAllByRole('button', { name: /history/i })
307 | .find(button => button.querySelector('svg'));
308 | await user.click(historyTab!);
309 |
310 | await waitFor(() => {
311 | expect(screen.queryByText('No processing history yet')).not.toBeInTheDocument();
312 | });
313 |
314 | // Look for any text that indicates entries are present
315 | await waitFor(() => {
316 | const hasEntries = screen.queryAllByText(/entries/).length > 0;
317 | const hasLoad = screen.queryByText('Load') !== null;
318 | const hasCopy = screen.queryByText('Copy') !== null;
319 | expect(hasEntries || hasLoad || hasCopy).toBeTruthy();
320 | });
321 | });
322 |
323 | it('should load data from history when Load button is clicked', async () => {
324 | const user = userEvent.setup();
325 | render();
326 |
327 | // Wait for parsing to complete and Save to History button to appear
328 | await waitFor(() => {
329 | expect(screen.getByText('Save to History')).toBeInTheDocument();
330 | });
331 |
332 | // Add to history first
333 | await user.click(screen.getByText('Save to History'));
334 |
335 | // Change input
336 | const textarea = screen.getByPlaceholderText(/Enter KLV data/);
337 | await user.clear(textarea);
338 | await user.type(textarea, 'DIFFERENT_DATA');
339 |
340 | // Go to history and load previous entry
341 | const historyTab = screen.getAllByRole('button', { name: /history/i })
342 | .find(button => button.querySelector('svg'));
343 | await user.click(historyTab!);
344 | const loadButton = screen.getByText('Load');
345 | await user.click(loadButton);
346 |
347 | // Should switch back to extractor tab with original data
348 | expect(screen.getByRole('button', { name: /extractor/i })).toHaveClass('border-blue-500', 'text-blue-600');
349 | const updatedTextarea = screen.getByPlaceholderText(/Enter KLV data/);
350 | expect(updatedTextarea).toHaveValue('00206AB48DE026044577');
351 | });
352 |
353 | it('should clear all history when Clear All History is clicked', async () => {
354 | const user = userEvent.setup();
355 | render();
356 |
357 | // Add to history first
358 | await user.click(screen.getByText('Save to History'));
359 |
360 | // Go to history tab
361 | const historyTab = screen.getAllByRole('button', { name: /history/i })
362 | .find(button => button.querySelector('svg'));
363 | await user.click(historyTab!);
364 |
365 | // Clear all history
366 | const clearAllButton = screen.getByText('Clear All History');
367 | await user.click(clearAllButton);
368 |
369 | expect(screen.getByText('No processing history yet')).toBeInTheDocument();
370 | });
371 |
372 | it.skip('should copy history entry data when Copy button is clicked', async () => {
373 | const user = userEvent.setup();
374 | render();
375 |
376 | // Wait for parsing to complete and Save to History button to appear
377 | await waitFor(() => {
378 | expect(screen.getByText('Save to History')).toBeInTheDocument();
379 | });
380 |
381 | // Add to history first
382 | await user.click(screen.getByText('Save to History'));
383 |
384 | // Go to history tab and copy
385 | const historyTab = screen.getAllByRole('button', { name: /history/i })
386 | .find(button => button.querySelector('svg'));
387 | await user.click(historyTab!);
388 |
389 | // Debug: Check if Copy button exists and is the right one
390 | const copyButtons = screen.queryAllByText('Copy');
391 | expect(copyButtons).toHaveLength(1);
392 |
393 | const copyButton = copyButtons[0];
394 | await user.click(copyButton);
395 |
396 | // Check that mock was called (don't wait for async)
397 | expect(mockWriteText).toHaveBeenCalled();
398 | expect(mockWriteText).toHaveBeenCalledWith('00206AB48DE026044577');
399 | });
400 | });
401 |
402 | describe('Builder Integration', () => {
403 | it('should navigate to extractor when KLV is built', async () => {
404 | const user = userEvent.setup();
405 | render();
406 |
407 | // Go to builder tab
408 | await user.click(screen.getByRole('button', { name: /builder/i }));
409 |
410 | // Add a value and build KLV
411 | const valueInput = screen.getByPlaceholderText('Enter hex value...');
412 | await user.type(valueInput, 'TEST123');
413 |
414 | const buildButton = screen.getByText('Build KLV String');
415 | await user.click(buildButton);
416 |
417 | // Should switch to extractor tab with built KLV
418 | expect(screen.getByRole('button', { name: /extractor/i })).toHaveClass('border-blue-500', 'text-blue-600');
419 | const textarea = screen.getByPlaceholderText(/Enter KLV data/);
420 | expect(textarea).toHaveValue('00207TEST123');
421 | });
422 | });
423 |
424 | describe('File Upload Integration', () => {
425 | it('should handle file upload and switch to extractor', async () => {
426 | render();
427 |
428 | const fileInput = screen.getByLabelText('Upload KLV data file');
429 | const mockFile = new File(['04210000050010008USD'], 'test.txt', { type: 'text/plain' });
430 |
431 | // Mock the text() method
432 | File.prototype.text = vi.fn().mockResolvedValue('04210000050010008USD');
433 |
434 | // Simulate file upload
435 | const event = {
436 | target: { files: [mockFile] }
437 | };
438 |
439 | fireEvent.change(fileInput, event);
440 |
441 | await waitFor(() => {
442 | const textarea = screen.getByPlaceholderText(/Enter KLV data/);
443 | expect(textarea).toHaveValue('04210000050010008USD');
444 | });
445 | });
446 | });
447 |
448 | describe('Export Integration', () => {
449 | it('should show export buttons when data is present', () => {
450 | render();
451 |
452 | expect(screen.getByText('JSON')).toBeInTheDocument();
453 | expect(screen.getByText('CSV')).toBeInTheDocument();
454 | expect(screen.getByText('Table')).toBeInTheDocument();
455 | });
456 | });
457 |
458 | describe('Responsive Design', () => {
459 | it('should have proper grid layouts for responsive design', () => {
460 | render();
461 |
462 | const inputGrid = screen.getByText('KLV Data Input').closest('.grid');
463 | expect(inputGrid).toHaveClass('md:grid-cols-2');
464 |
465 | const referenceGrid = screen.getByText('Format Structure:').closest('.grid');
466 | expect(referenceGrid).toHaveClass('md:grid-cols-4');
467 | });
468 | });
469 |
470 | describe('Error Handling', () => {
471 | it('should handle empty input gracefully', async () => {
472 | const user = userEvent.setup();
473 | render();
474 |
475 | const textarea = screen.getByPlaceholderText(/Enter KLV data/);
476 | await user.clear(textarea);
477 |
478 | // Should not show results section when empty
479 | expect(screen.queryByText(/Parsed KLV Data/)).not.toBeInTheDocument();
480 | expect(screen.queryByText('Total Entries')).not.toBeInTheDocument();
481 | });
482 |
483 | it('should maintain state consistency across tab switches', async () => {
484 | const user = userEvent.setup();
485 | render();
486 |
487 | const textarea = screen.getByPlaceholderText(/Enter KLV data/);
488 | await user.clear(textarea);
489 | await user.type(textarea, 'CUSTOM_DATA');
490 |
491 | // Switch tabs and come back
492 | await user.click(screen.getByRole('button', { name: /builder/i }));
493 | await user.click(screen.getByRole('button', { name: /extractor/i }));
494 |
495 | const updatedTextarea = screen.getByPlaceholderText(/Enter KLV data/);
496 | expect(updatedTextarea).toHaveValue('CUSTOM_DATA');
497 | });
498 | });
499 | });
500 |
501 |
--------------------------------------------------------------------------------
/src/App.tsx:
--------------------------------------------------------------------------------
1 | import React, { useState, useMemo } from 'react';
2 | import { Search, Copy, Trash2, Eye, EyeOff, Info, Database, Settings, FileText } from 'lucide-react';
3 |
4 | // Import components
5 | import FileUpload from './components/FileUpload';
6 | import ExportPanel from './components/ExportPanel';
7 | import Statistics from './components/Statistics';
8 | import KLVBuilder from './components/KLVBuilder';
9 | import BatchProcessor from './components/BatchProcessor';
10 |
11 | // Import utilities
12 | import KLVParser, { KLVEntry } from './utils/KLVParser';
13 |
14 | interface HistoryEntry {
15 | id: number;
16 | label: string;
17 | data: string;
18 | timestamp: string;
19 | resultCount: number;
20 | }
21 |
22 | interface Tab {
23 | id: string;
24 | label: string;
25 | icon: React.ComponentType<{ size?: number }>;
26 | }
27 |
28 | interface BatchResult {
29 | line: number;
30 | input: string;
31 | results: KLVEntry[];
32 | errors: string[];
33 | }
34 |
35 | const App: React.FC = () => {
36 | const [activeTab, setActiveTab] = useState('extractor');
37 | const [klvInput, setKlvInput] = useState('00206AB48DE026044577');
38 | const [searchTerm, setSearchTerm] = useState('');
39 | const [showRaw, setShowRaw] = useState(false);
40 | const [, setBatchResults] = useState([]);
41 | const [history, setHistory] = useState([]);
42 |
43 | // Parse KLV data
44 | const { results, errors } = useMemo(() => KLVParser.parse(klvInput), [klvInput]);
45 |
46 | // Filter results based on search
47 | const filteredResults = useMemo(() => {
48 | if (!searchTerm) return results;
49 | const term = searchTerm.toLowerCase();
50 | return results.filter(item =>
51 | item.key.includes(searchTerm) ||
52 | item.value.toLowerCase().includes(term) ||
53 | item.name.toLowerCase().includes(term)
54 | );
55 | }, [results, searchTerm]);
56 |
57 | // Tab configuration
58 | const tabs: Tab[] = [
59 | { id: 'extractor', label: 'Extractor', icon: Database },
60 | { id: 'builder', label: 'Builder', icon: Settings },
61 | { id: 'batch', label: 'Batch', icon: FileText },
62 | { id: 'history', label: 'History', icon: Copy }
63 | ];
64 |
65 | // Sample data for testing
66 | const sampleData = [
67 | '00206AB48DE026044577',
68 | '04210000050010008USD04305Test Merchant25103EMV25107Visa',
69 | '04210050026055422600512345678042036MERCHANT_ID_12343015Test Transaction'
70 | ];
71 |
72 | // Utility functions
73 | const addToHistory = (data: string, label?: string) => {
74 | const entry: HistoryEntry = {
75 | id: Date.now(),
76 | label: label || `Entry ${history.length + 1}`,
77 | data,
78 | timestamp: new Date().toLocaleString(),
79 | resultCount: KLVParser.parse(data).results.length
80 | };
81 | setHistory([entry, ...history.slice(0, 9)]); // Keep last 10
82 | };
83 |
84 | const loadFromHistory = (data: string) => {
85 | setKlvInput(data);
86 | setActiveTab('extractor');
87 | };
88 |
89 | const handleFileLoad = (content: string, filename: string) => {
90 | setKlvInput(content);
91 | addToHistory(content, `File: ${filename}`);
92 | setActiveTab('extractor');
93 | };
94 |
95 | const handleBatchProcess = (results: BatchResult[]) => {
96 | setBatchResults(results);
97 | };
98 |
99 | const handleBuilderResult = (klvString: string) => {
100 | setKlvInput(klvString);
101 | addToHistory(klvString, 'Built KLV');
102 | setActiveTab('extractor');
103 | };
104 |
105 | const copyToClipboard = async (text: string) => {
106 | try {
107 | await navigator.clipboard.writeText(text);
108 | // You could add a toast notification here
109 | } catch (err) {
110 | console.error('Failed to copy:', err);
111 | }
112 | };
113 |
114 | return (
115 |
116 |
117 | {/* Header */}
118 |
119 |
120 | KLV Data Extraction Suite
121 |
122 |
123 | Complete toolkit for KLV data processing, parsing, and analysis
124 |
125 |
126 |
127 | {/* Navigation Tabs */}
128 |
129 |
130 |
149 |
150 |
151 | {/* Tab Content */}
152 |
153 | {/* KLV Extractor Tab */}
154 | {activeTab === 'extractor' && (
155 |
156 | {/* Input Section */}
157 |
158 |
159 |
162 |
188 |
189 |
190 |
193 |
194 |
195 |
196 |
197 | {/* Results Section */}
198 | {(results.length > 0 || errors.length > 0) && (
199 | <>
200 |
201 |
202 | {/* Error Display */}
203 | {errors.length > 0 && (
204 |
205 |
206 |
207 |
210 |
Parsing Errors
211 |
212 |
213 |
214 |
215 | {errors.map((error, index) => (
216 | - {error}
217 | ))}
218 |
219 |
220 |
221 | )}
222 |
223 | {/* Success and Data Display */}
224 | {results.length > 0 && (
225 |
226 |
227 |
228 | Parsed KLV Data ({results.length} entries)
229 |
230 |
231 | {/* Search */}
232 |
233 |
234 | setSearchTerm(e.target.value)}
239 | className="pl-8 pr-3 py-2 border border-gray-300 rounded text-sm w-64 focus:ring-2 focus:ring-blue-500 focus:border-transparent"
240 | />
241 |
242 |
243 | {/* Toggle Raw View */}
244 |
251 |
252 | {/* Export */}
253 |
254 |
255 |
256 |
257 | {/* Raw Data Display */}
258 | {showRaw && (
259 |
260 | Raw KLV:
261 | {klvInput.replace(/\s/g, '')}
262 |
263 | )}
264 |
265 | {/* KLV Entries */}
266 |
267 | {filteredResults.map((item, i) => (
268 |
269 |
270 |
271 |
272 | Key {item.key}
273 |
274 |
275 | {item.name}
276 |
277 |
278 | Len: {item.len}
279 |
280 |
281 | Pos: {item.pos}
282 |
283 |
284 |
291 |
292 |
293 |
294 |
297 |
298 | {item.formattedValue ? (
299 |
300 |
{item.formattedValue}
301 |
Raw: {item.value}
302 |
303 | ) : (
304 | item.value ||
Empty
305 | )}
306 |
307 |
308 |
309 | ))}
310 |
311 |
312 | {/* No Search Results */}
313 | {filteredResults.length === 0 && searchTerm && (
314 |
315 |
316 |
317 | No entries match "{searchTerm}"
318 |
319 |
325 |
326 | )}
327 |
328 | {/* Add to History */}
329 | {results.length > 0 && errors.length === 0 && (
330 |
331 |
337 |
338 | )}
339 |
340 | )}
341 | >
342 | )}
343 |
344 | )}
345 |
346 | {/* KLV Builder Tab */}
347 | {activeTab === 'builder' &&
}
348 |
349 | {/* Batch Processor Tab */}
350 | {activeTab === 'batch' && (
351 |
352 |
353 |
354 | )}
355 |
356 | {/* History Tab */}
357 | {activeTab === 'history' && (
358 |
359 |
360 |
Processing History
361 | {history.length > 0 && (
362 |
368 | )}
369 |
370 |
371 | {history.length === 0 ? (
372 |
373 |
374 |
375 | No processing history yet
376 |
377 |
378 | Parse some KLV data and save it to see it here
379 |
380 |
381 | ) : (
382 |
383 | {history.map((entry) => (
384 |
385 |
386 |
387 |
{entry.label}
388 |
389 | {entry.timestamp} • {entry.resultCount} entries
390 |
391 |
392 |
393 |
399 |
405 |
406 |
407 |
408 | {entry.data.length > 200 ? `${entry.data.slice(0, 200)}...` : entry.data}
409 |
410 |
411 | ))}
412 |
413 | )}
414 |
415 | )}
416 |
417 |
418 |
419 | {/* Quick Reference Footer */}
420 |
421 |
422 |
423 | KLV Format Quick Reference
424 |
425 |
426 |
427 |
Format Structure:
428 |
KKKLLVVV...
429 |
3-digit Key + 2-digit Length + Value
430 |
431 |
432 |
Example:
433 |
00206AB48DE
434 |
Key=002, Len=06, Val=AB48DE
435 |
436 |
437 |
Key Range:
438 |
002 - 999
439 |
{Object.keys(KLVParser.definitions).length} defined keys
440 |
441 |
442 |
Features:
443 |
Parse, Build, Batch
444 |
Export to JSON, CSV, Table
445 |
446 |
447 |
448 |
449 | {/* Version Footer */}
450 |
451 |
452 | Version {__APP_VERSION__}
453 | •
454 | Built: {new Date(__BUILD_DATE__).toLocaleDateString()} {new Date(__BUILD_DATE__).toLocaleTimeString()}
455 |
456 |
457 |
458 |
459 | );
460 | };
461 |
462 | export default App;
--------------------------------------------------------------------------------