diff --git a/.babelrc b/.babelrc index 8aa924d..1320b9a 100644 --- a/.babelrc +++ b/.babelrc @@ -1,3 +1,3 @@ { "presets": ["@babel/preset-env"] -} \ No newline at end of file +} diff --git a/.github/workflows/e2e-tests.yml b/.github/workflows/e2e-tests.yml new file mode 100644 index 0000000..537c7c9 --- /dev/null +++ b/.github/workflows/e2e-tests.yml @@ -0,0 +1,51 @@ +name: E2E Tests + +on: + push: + branches: [main, gh-pages] + pull_request: + branches: [main, gh-pages] + merge_group: + +jobs: + e2e: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + browser: [chromium, firefox, webkit] + name: E2E Tests - ${{ matrix.browser }} + + steps: + - uses: actions/checkout@v4 + + - name: Use Node.js 22.x + uses: actions/setup-node@v4 + with: + node-version: "22.x" + cache: "npm" + + - name: Install dependencies + run: npm ci + + - name: Install Playwright Browsers + run: npx playwright install --with-deps ${{ matrix.browser }} + + - name: Run E2E tests + run: npx playwright test --project=${{ matrix.browser }} + + - name: Upload test results + uses: actions/upload-artifact@v4 + if: ${{ !cancelled() }} + with: + name: playwright-report-${{ matrix.browser }} + path: playwright-report/ + retention-days: 30 + + - name: Upload test videos + uses: actions/upload-artifact@v4 + if: ${{ !cancelled() }} + with: + name: test-videos-${{ matrix.browser }} + path: test-results/ + retention-days: 7 diff --git a/.github/workflows/quality-check.yml b/.github/workflows/quality-check.yml new file mode 100644 index 0000000..a48c8d1 --- /dev/null +++ b/.github/workflows/quality-check.yml @@ -0,0 +1,27 @@ +name: Quality Check + +on: + push: + branches: [main, gh-pages] + pull_request: + branches: [main, gh-pages] + merge_group: + +jobs: + quality: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Use Node.js 22.x + uses: actions/setup-node@v4 + with: + node-version: "22.x" + cache: "npm" + + - name: Install dependencies + run: npm ci + + - name: Check Prettier formatting + run: npm run format:check diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..b5c97e6 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,33 @@ +name: Run Tests + +on: + push: + branches: [main, gh-pages] + pull_request: + branches: [main, gh-pages] + merge_group: + +jobs: + test: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Use Node.js 22.x + uses: actions/setup-node@v4 + with: + node-version: "22.x" + cache: "npm" + + - name: Install dependencies + run: npm ci + + - name: Run tests with coverage + run: npm run test:coverage + + - name: Upload coverage reports + uses: codecov/codecov-action@v5 + with: + token: ${{ secrets.CODECOV_TOKEN }} + fail_ci_if_error: false diff --git a/.gitignore b/.gitignore index 0bd0f7f..b8800a0 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,15 @@ node_modules *.log .vscode +coverage/ + +# Excel files (to prevent accidental commits of sensitive data) +*.xls +*.xlsx +*.xlsm +*.xlsb + +# Playwright +/test-results/ +/playwright-report/ +/playwright/.cache/ diff --git a/.husky/pre-commit b/.husky/pre-commit new file mode 100644 index 0000000..c27d889 --- /dev/null +++ b/.husky/pre-commit @@ -0,0 +1 @@ +lint-staged diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000..004c7fd --- /dev/null +++ b/.prettierignore @@ -0,0 +1,11 @@ +node_modules +coverage +*.log +.vscode +dist +build +tmp +*.min.js +*.min.css +playwright-report +test-results \ No newline at end of file diff --git a/README.md b/README.md index 4cbb81b..a2157ac 100644 --- a/README.md +++ b/README.md @@ -1,17 +1,21 @@ # ynab-csv +Tool for making your CSV and Excel files ready to import into YNAB. -Tool for making your CSV files ready to import into YNAB. - -http://aniav.github.io/ynab-csv/ - +http://toshi38.github.io/ynab-csv/ ## How to Use 1. Visit the link above. -2. Drop or select the the csv file you want to make ready for YNAB. -3. For each column in the YNAB data file, choose which column you want to pull from your source data file. -4. Save the new YNAB file and you are ready to import that right into YNAB! +2. Drop or select the CSV or Excel file you want to make ready for YNAB. +3. For Excel files with multiple worksheets, select the desired worksheet from the dropdown. +4. For each column in the YNAB data file, choose which column you want to pull from your source data file. +5. Save the new YNAB file and you are ready to import that right into YNAB! + +## Supported File Formats + +- **CSV files** (.csv) - All standard CSV formats with configurable delimiters and encodings +- **Excel files** (.xlsx, .xls, .xlsm, .xlsb) - Full support for Excel spreadsheets including multi-sheet workbooks ## Multiple Profiles @@ -37,7 +41,6 @@ To run the project locally using Docker Compose: Your data never leaves your computer. All the processing happens locally. - ## Reporting Issues If you have any other issues or suggestions, go to https://github.com/aniav/ynab-csv/issues and create an issue if one doesn't already exist. If the issue has to do with your csv file, please create a new gist (https://gist.github.com/) with the content of the CSV file and share the link in the issue. If you tweak the CSV file before sharing, just make sure whatever version you end up sharing still causes the problem you describe. @@ -46,10 +49,11 @@ If you have any other issues or suggestions, go to https://github.com/aniav/ynab 1. Fork and clone the project 2. `cd` into project -3. Run `npm install` # You will need to install node and npm if it is not already -4. Run `npm start` # when running in Windows, modify package.json and replace "open" with "start" - * Optional: run `npm run bs` instead to use [Browsersync](https://browsersync.io/) +3. Run `npm install` # You will need to install node and npm if it is not already +4. Run `npm start` # when running in Windows, modify package.json and replace "open" with "start" + +- Optional: run `npm run bs` instead to use [Browsersync](https://browsersync.io/) + 5. Make your changes locally and test them to make sure they work 6. Commit those changes and push to your forked repository 7. Make a new pull request - diff --git a/app.css b/app.css index 7dc7860..d8cefb8 100644 --- a/app.css +++ b/app.css @@ -1,28 +1,192 @@ -html, body, .dropzone { height: 100%; } +html, +body, +.dropzone { + height: 100%; +} /* Loading helpers*/ -body:not(.angular_loaded) .show_on_load { display: none; } -.angular_loaded .hide_on_load { display: none; } -#angular_is_loading { position: fixed; top: 0; right: 0; bottom: 0; left: 0; background-color: rgba(0,0,0,.5); color: white; font-size: 70px; text-align: center; padding-top: 200px; box-shadow: 0 0 10px rgba(0,0,0,.5); transition: all 2s; -webkit-transition: all 2s; } -body.angular_loaded #angular_is_loading { display: none; top: 3000px; bottom: -3000px; } - +body:not(.angular_loaded) .show_on_load { + display: none; +} +.angular_loaded .hide_on_load { + display: none; +} +#angular_is_loading { + position: fixed; + top: 0; + right: 0; + bottom: 0; + left: 0; + background-color: rgba(0, 0, 0, 0.5); + color: white; + font-size: 70px; + text-align: center; + padding-top: 200px; + box-shadow: 0 0 10px rgba(0, 0, 0, 0.5); + transition: all 2s; + -webkit-transition: all 2s; +} +body.angular_loaded #angular_is_loading { + display: none; + top: 3000px; + bottom: -3000px; +} /* Header */ -#header_nav { position: fixed; top: 0; left: 0; right: 0; padding: 10px; background-color: rgb(240, 240, 240); z-index: 1; box-shadow: 0 0 10px rgba(0,0,0,.5); } -#header_nav h1 { display: inline-block; font-size: 20px; margin: 0; vertical-align: middle; margin-right: 15px;} - +#header_nav { + position: fixed; + top: 0; + left: 0; + right: 0; + padding: 10px; + background-color: rgb(240, 240, 240); + z-index: 1; + box-shadow: 0 0 10px rgba(0, 0, 0, 0.5); +} +#header_nav h1 { + display: inline-block; + font-size: 20px; + margin: 0; + vertical-align: middle; + margin-right: 15px; +} /* Dropzone */ -.dropzone { padding-top: 54px; } -.dropzone.dragging { background-color: #D2E4F8; } +.dropzone { + padding-top: 54px; +} + +/* Responsive padding for fluid containers */ +.container-fluid { + padding-left: 15px; + padding-right: 15px; +} + +@media (min-width: 768px) { + .container-fluid { + padding-left: 30px; + padding-right: 30px; + } +} + +@media (min-width: 1200px) { + .container-fluid { + padding-left: 50px; + padding-right: 50px; + } +} +.dropzone.dragging { + background-color: #d2e4f8; +} + +#upload_wrapper { + text-align: center; + max-width: 600px; + margin: 0 auto; +} +#upload_wrapper #drop_text { + font-size: 60px; + padding-top: 150px; +} +#upload_wrapper .fileUpload { + position: relative; + overflow: hidden; + margin: 10px; +} +#upload_wrapper .fileUpload input { + position: absolute; + top: 0; + right: 0; + margin: 0; + padding: 0; + font-size: 20px; + cursor: pointer; + opacity: 0; + filter: alpha(opacity=0); +} + +#tool_wrapper { + margin-bottom: 40px; +} +#tool_wrapper .table-container { + overflow: auto; +} + +.dropdown-form { + width: 200px; +} + +/* When file-parsing-settings is inside a dropdown */ +.dropdown-form .file-parsing-settings { + display: block; +} + +.dropdown-form .file-parsing-settings .form-group { + flex: none; + min-width: auto; + margin-bottom: 0.5rem; +} + +.dropdown-form .file-parsing-settings .form-control { + width: 100%; +} + +/* Settings section */ +.settings-section { + margin-top: 10px; +} + +.settings-content { + transition: all 0.3s ease; +} + +.file-parsing-settings { + display: flex; + flex-wrap: wrap; + gap: 15px; +} + +.file-parsing-settings .form-group { + flex: 1; + min-width: 200px; + margin-bottom: 0; +} + +.file-parsing-settings .form-check { + flex: 1 1 100%; +} + +/* Auto-match notification styles */ +.alert-dismissible .close { + padding: 0.5rem 1rem; +} + +/* Saved configs card on landing page */ +#upload_wrapper .card { + max-width: 400px; + margin: 20px auto 0; + text-align: left; +} -#upload_wrapper { text-align: center;} -#upload_wrapper #drop_text { font-size: 60px; padding-top: 150px; } -#upload_wrapper .fileUpload { position: relative; overflow: hidden; margin: 10px; } -#upload_wrapper .fileUpload input { position: absolute; top: 0; right: 0; margin: 0; padding: 0; font-size: 20px; cursor: pointer; opacity: 0; filter: alpha(opacity=0); } +#upload_wrapper .list-group-item { + padding: 0.75rem 1rem; +} +/* Config save button group */ +[data-testid="config-save-group"] { + margin-left: 5px; +} -#tool_wrapper { margin-bottom: 40px; } -#tool_wrapper .table-container { overflow: auto; } +/* Match confidence badge */ +.badge-pill { + font-size: 0.75em; + vertical-align: middle; +} -.dropdown-form {width: 200px;} \ No newline at end of file +/* Make buttons wrap nicely on smaller screens */ +.card-header .float-right { + display: flex; + flex-wrap: wrap; + gap: 5px; + justify-content: flex-end; +} diff --git a/docker-compose.yml b/docker-compose.yml index a291848..25e88bc 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,7 +1,7 @@ -version: '3' +version: "3" services: ynab-csv-app: build: . ports: - - ${PORT}:3000 \ No newline at end of file + - ${PORT}:3000 diff --git a/e2e/pages/ynab-converter.page.js b/e2e/pages/ynab-converter.page.js new file mode 100644 index 0000000..9e3372f --- /dev/null +++ b/e2e/pages/ynab-converter.page.js @@ -0,0 +1,361 @@ +export class YnabConverterPage { + constructor(page) { + this.page = page; + + // File upload elements + this.fileInput = page.locator('[data-testid="file-input"]'); + this.dropzone = page.locator('[data-testid="dropzone"]'); + this.uploadWrapper = page.locator('[data-testid="upload-wrapper"]'); + + // Data display elements + this.toolWrapper = page.locator('[data-testid="tool-wrapper"]'); + this.dataTable = page.locator('[data-testid="data-preview-table"]'); + this.fileTypeBadge = page.locator('[data-testid="file-type-badge"]'); + + // Excel worksheet selector + this.worksheetSelector = page.locator('[data-testid="worksheet-select"]'); + + // Configuration elements + this.encodingSelect = page.locator('[data-testid="encoding-select"]'); + this.delimiterSelect = page.locator('[data-testid="delimiter-select"]'); + this.startRowInput = page.locator('[data-testid="start-row-input"]'); + this.extraRowCheckbox = page.locator('[data-testid="extra-row-checkbox"]'); + this.configDropdown = page.locator('[data-testid="config-dropdown"]'); + + // Column mapping selects + this.columnMappingSelects = page.locator('[data-testid^="column-select-"]'); + + // Profile elements + this.profileSelect = page.locator('[data-testid="profile-select"]'); + + // Action buttons + this.downloadButton = page.locator('[data-testid="download-button"]'); + this.invertFlowsButton = page.locator( + '[data-testid="invert-flows-button"]', + ); + this.invertAmountButton = page.locator( + '[data-testid="invert-amount-button"]', + ); + this.toggleFormatButton = page.locator( + '[data-testid="toggle-format-button"]', + ); + this.reloadButton = page.locator('[data-testid="reload-button"]'); + + // Settings panel elements + this.settingsToggle = page.locator('[data-testid="settings-toggle"]'); + this.settingsContent = page.locator(".settings-content"); + + // Auto-matching configuration elements + this.autoApplyNotification = page.locator( + '[data-testid="auto-apply-notification"]', + ); + this.partialMatchSuggestion = page.locator( + '[data-testid="config-suggestion"]', + ); + this.savedConfigsList = page.locator('[data-testid="saved-configs-list"]'); + this.savedConfigsCard = page.locator('[data-testid="saved-configs-card"]'); + this.saveConfigButton = page.locator('[data-testid="save-config-btn"]'); + this.saveConfigDropdown = page.locator('[data-testid="config-save-group"]'); + } + + async goto() { + await this.page.goto("/"); + await this.page.waitForSelector(".show_on_load", { state: "visible" }); + } + + async uploadFile(filePath) { + await this.fileInput.setInputFiles(filePath); + await this.toolWrapper.waitFor({ state: "visible" }); + } + + async dragAndDropFile(filePath) { + const dataTransfer = await this.page.evaluateHandle( + () => new DataTransfer(), + ); + + await this.page.dispatchEvent('[data-testid="dropzone"]', "dragenter", { + dataTransfer, + }); + await this.page.dispatchEvent('[data-testid="dropzone"]', "dragover", { + dataTransfer, + }); + + await this.fileInput.setInputFiles(filePath); + const files = await this.fileInput.inputValue(); + + await this.page.dispatchEvent('[data-testid="dropzone"]', "drop", { + dataTransfer: await this.page.evaluateHandle((files) => { + const dt = new DataTransfer(); + // Simulate file drop + return dt; + }, files), + }); + + await this.toolWrapper.waitFor({ state: "visible" }); + } + + async selectWorksheet(index) { + await this.worksheetSelector.selectOption({ index: index.toString() }); + } + + async setColumnMapping(ynabColumn, csvColumn) { + const select = this.page.locator( + `[data-testid="column-select-${ynabColumn}"]`, + ); + + // Wait for the select to have options available and find the option value + const optionValue = await this.page.evaluate( + ({ selectId, targetColumn }) => { + const select = document.querySelector(`[data-testid="${selectId}"]`); + if (!select) return false; + const options = Array.from(select.options); + + // Try to find option that matches the target column + const matchingOption = options.find( + (option) => + option.textContent.includes(targetColumn) || + option.value.includes(targetColumn) || + option.label === targetColumn || + option.label.startsWith(targetColumn), // Handle "Transaction Date (1)" format + ); + + return matchingOption ? matchingOption.value : false; + }, + { selectId: `column-select-${ynabColumn}`, targetColumn: csvColumn }, + ); + + if (optionValue) { + await select.selectOption(optionValue); + + // Wait for AngularJS to process the change and update the preview + await this.page.waitForFunction( + () => { + // Check if Angular scope exists and has processed the change + const scope = angular.element(document.body).scope(); + return scope && scope.preview && scope.preview.length > 0; + }, + { timeout: 5000 }, + ); + + // Small additional wait to ensure DOM updates are complete + await this.page.waitForTimeout(100); + } else { + throw new Error( + `Could not find option for column: ${csvColumn} in ${ynabColumn} select`, + ); + } + } + + async getPreviewData() { + // Use data-testid selectors for reliable element targeting + const rows = await this.page + .locator( + '[data-testid="data-preview-table"] [data-testid^="preview-row-"]', + ) + .all(); + const data = []; + + for (const row of rows) { + const cells = await row + .locator('[data-testid^="preview-cell-"]') + .allTextContents(); + data.push(cells); + } + + return data; + } + + async downloadConvertedFile() { + // Ensure data is processed and download button is available + await this.downloadButton.waitFor({ state: "visible" }); + + // Wait for any pending Angular operations to complete + await this.page.waitForFunction( + () => { + const scope = angular.element(document.body).scope(); + return scope && scope.preview && scope.preview.length > 0; + }, + { timeout: 5000 }, + ); + + const downloadPromise = this.page.waitForEvent("download"); + await this.downloadButton.click(); + const download = await downloadPromise; + return download; + } + + async openConfigDropdown() { + await this.configDropdown.click(); + await this.page.locator(".dropdown-menu").waitFor({ state: "visible" }); + } + + async setEncoding(encoding) { + await this.openConfigDropdown(); + await this.encodingSelect.selectOption(encoding); + } + + async setDelimiter(delimiter) { + await this.openConfigDropdown(); + await this.delimiterSelect.selectOption(delimiter); + } + + async setStartRow(row) { + await this.openConfigDropdown(); + await this.startRowInput.fill(row.toString()); + } + + async toggleExtraRow() { + await this.openConfigDropdown(); + await this.extraRowCheckbox.click(); + } + + async getCurrentProfile() { + return await this.profileSelect.inputValue(); + } + + async selectProfile(profileName) { + await this.profileSelect.selectOption(profileName); + } + + async toggleSettingsPanel() { + await this.settingsToggle.click(); + // Wait for animation to complete + await this.page.waitForTimeout(300); + } + + async isSettingsPanelVisible() { + return await this.settingsContent.isVisible(); + } + + async changeSettingAfterUpload(settingType, value) { + // Ensure settings panel is open + if (!(await this.isSettingsPanelVisible())) { + await this.toggleSettingsPanel(); + } + + // Find the setting element within the settings panel + const settingsContainer = this.settingsContent; + + switch (settingType) { + case "encoding": + await settingsContainer + .locator('[data-testid="encoding-select"]') + .selectOption(value); + break; + case "delimiter": + await settingsContainer + .locator('[data-testid="delimiter-select"]') + .selectOption(value); + break; + case "startRow": + await settingsContainer + .locator('[data-testid="start-row-input"]') + .fill(value.toString()); + break; + case "extraRow": + await settingsContainer + .locator('[data-testid="extra-row-checkbox"]') + .click(); + break; + case "worksheet": + await settingsContainer + .locator('[data-testid="worksheet-select"]') + .selectOption({ index: value.toString() }); + break; + default: + throw new Error(`Unknown setting type: ${settingType}`); + } + + // Wait for re-parse to complete + await this.page.waitForTimeout(500); + } + + // Auto-matching configuration methods + async isAutoApplied() { + return await this.autoApplyNotification.isVisible(); + } + + async getAutoApplyMessage() { + if (await this.autoApplyNotification.isVisible()) { + return await this.autoApplyNotification.textContent(); + } + return null; + } + + async dismissAutoApply() { + const closeButton = this.autoApplyNotification.locator("button.close"); + if (await closeButton.isVisible()) { + await closeButton.click(); + } + } + + async saveConfigAs(name) { + // Handle the prompt dialog BEFORE triggering it + this.page.once("dialog", async (dialog) => { + await dialog.accept(name); + }); + + // Click the dropdown toggle to show options + const dropdownToggle = this.saveConfigDropdown.locator( + ".dropdown-toggle-split", + ); + await dropdownToggle.click(); + + // Click "Save as..." option + const saveAsButton = this.page.locator('[data-testid="save-config-as"]'); + await saveAsButton.click(); + } + + async getSavedConfigsCount() { + const items = this.page.locator('[data-testid="saved-config-item"]'); + return await items.count(); + } + + async getSavedConfigNames() { + const names = []; + const items = this.page.locator('[data-testid="saved-config-item"]'); + const count = await items.count(); + + for (let i = 0; i < count; i++) { + const nameEl = items.nth(i).locator("strong"); + const name = await nameEl.textContent(); + names.push(name.trim()); + } + + return names; + } + + async deleteConfigAt(index) { + const items = this.page.locator('[data-testid="saved-config-item"]'); + const deleteButton = items + .nth(index) + .locator('[data-testid="delete-config-btn"]'); + await deleteButton.click(); + } + + async renameConfigAt(index, newName) { + // Handle the prompt dialog BEFORE triggering it + this.page.once("dialog", async (dialog) => { + await dialog.accept(newName); + }); + + const items = this.page.locator('[data-testid="saved-config-item"]'); + const renameButton = items + .nth(index) + .locator('[data-testid="rename-config-btn"]'); + await renameButton.click(); + } + + async clearLocalStorage() { + await this.page.evaluate(() => { + localStorage.removeItem("knownConfigurations"); + }); + } + + async getLocalStorageConfigs() { + return await this.page.evaluate(() => { + const stored = localStorage.getItem("knownConfigurations"); + return stored ? JSON.parse(stored) : null; + }); + } +} diff --git a/e2e/specs/auto-config.spec.js b/e2e/specs/auto-config.spec.js new file mode 100644 index 0000000..ead794b --- /dev/null +++ b/e2e/specs/auto-config.spec.js @@ -0,0 +1,285 @@ +import { test, expect } from "@playwright/test"; +import { YnabConverterPage } from "../pages/ynab-converter.page"; +import path from "path"; + +test.describe("Auto-matching Configuration", () => { + let ynabPage; + + test.beforeEach(async ({ page }) => { + ynabPage = new YnabConverterPage(page); + await ynabPage.goto(); + // Clear any saved configurations before each test + await ynabPage.clearLocalStorage(); + await page.reload(); + await ynabPage.page.waitForSelector(".show_on_load", { state: "visible" }); + }); + + test.describe("Auto-save on download", () => { + test("saves configuration when downloading converted file", async ({ + page, + }) => { + // Upload chase_statement_2024.csv + const filePath = path.resolve("test_files/chase_statement_2024.csv"); + await ynabPage.uploadFile(filePath); + + // Map columns + await ynabPage.setColumnMapping("Date", "Date"); + await ynabPage.setColumnMapping("Payee", "Description"); + await ynabPage.setColumnMapping("Outflow", "Amount"); + + // Download the file (which should auto-save config) + await ynabPage.downloadConvertedFile(); + + // Verify config was saved to localStorage + const configs = await ynabPage.getLocalStorageConfigs(); + expect(configs).not.toBeNull(); + expect(Object.keys(configs.configs).length).toBeGreaterThan(0); + }); + }); + + test.describe("Auto-apply on upload", () => { + test("auto-applies configuration when uploading file with matching headers", async ({ + page, + }) => { + // First, upload and configure chase_statement_2024.csv + const filePath2024 = path.resolve("test_files/chase_statement_2024.csv"); + await ynabPage.uploadFile(filePath2024); + + await ynabPage.setColumnMapping("Date", "Date"); + await ynabPage.setColumnMapping("Payee", "Description"); + await ynabPage.setColumnMapping("Outflow", "Amount"); + + // Download to save the config + await ynabPage.downloadConvertedFile(); + + // Reload the page to reset state + await page.reload(); + await ynabPage.page.waitForSelector(".show_on_load", { + state: "visible", + }); + + // Now upload chase_statement_2025.csv (same headers) + const filePath2025 = path.resolve("test_files/chase_statement_2025.csv"); + await ynabPage.uploadFile(filePath2025); + + // Should show auto-apply notification + await expect(ynabPage.autoApplyNotification).toBeVisible({ + timeout: 5000, + }); + + // The notification message should indicate auto-apply + const message = await ynabPage.getAutoApplyMessage(); + expect(message).toContain("auto"); + }); + + test("does not auto-apply when uploading file with different headers", async ({ + page, + }) => { + // First, upload and configure chase_statement_2024.csv + const chaseFile = path.resolve("test_files/chase_statement_2024.csv"); + await ynabPage.uploadFile(chaseFile); + + await ynabPage.setColumnMapping("Date", "Date"); + await ynabPage.setColumnMapping("Payee", "Description"); + + // Download to save the config + await ynabPage.downloadConvertedFile(); + + // Reload the page to reset state + await page.reload(); + await ynabPage.page.waitForSelector(".show_on_load", { + state: "visible", + }); + + // Now upload wells_fargo file (different headers) + const wellsFargoFile = path.resolve( + "test_files/wells_fargo_checking.csv", + ); + await ynabPage.uploadFile(wellsFargoFile); + + // Should NOT show auto-apply notification + await expect(ynabPage.autoApplyNotification).not.toBeVisible(); + }); + }); + + test.describe("startAtRow handling", () => { + test("auto-applies configuration with correct startAtRow for files with metadata rows", async ({ + page, + }) => { + // Set start row to 3 BEFORE uploading (dropdown is only visible on landing page) + await ynabPage.openConfigDropdown(); + await ynabPage.startRowInput.fill("3"); + + // Wait for Angular to process + await page.waitForTimeout(300); + + // Close dropdown + await page.click("body"); + + // Upload file with headers on row 3 + const metadataFile = path.resolve("test_files/metadata_header_row3.csv"); + await ynabPage.uploadFile(metadataFile); + + // Map columns + await ynabPage.setColumnMapping("Date", "Date"); + await ynabPage.setColumnMapping("Payee", "Payee"); + await ynabPage.setColumnMapping("Outflow", "Amount"); + + // Download to save config + await ynabPage.downloadConvertedFile(); + + // Reload and upload second file with same structure + await page.reload(); + await ynabPage.page.waitForSelector(".show_on_load", { + state: "visible", + }); + + const metadataFileV2 = path.resolve( + "test_files/metadata_header_row3_v2.csv", + ); + await ynabPage.uploadFile(metadataFileV2); + + // Should auto-apply with correct startAtRow + await expect(ynabPage.autoApplyNotification).toBeVisible({ + timeout: 5000, + }); + + // Verify the preview data has correct content (startAtRow was applied) + const previewData = await ynabPage.getPreviewData(); + // The first row should have a date, not metadata text (trim whitespace) + expect(previewData[0][0].trim()).toMatch(/^\d{4}-\d{2}-\d{2}/); + }); + }); + + test.describe("Semicolon delimiter", () => { + test("preserves semicolon delimiter setting in saved config", async ({ + page, + }) => { + // Set delimiter to semicolon BEFORE uploading (dropdown is only visible on landing page) + await ynabPage.openConfigDropdown(); + await ynabPage.delimiterSelect.selectOption(";"); + + // Wait for Angular to process + await page.waitForTimeout(300); + + // Close dropdown + await page.click("body"); + + // Upload kontoutdrag file (semicolon delimited) + const kontoutdragFile = path.resolve( + "test_files/kontoutdrag 20251227-0654.csv", + ); + await ynabPage.uploadFile(kontoutdragFile); + + // Map columns + await ynabPage.setColumnMapping("Date", "Booking date"); + await ynabPage.setColumnMapping("Payee", "Text"); + await ynabPage.setColumnMapping("Outflow", "Amount"); + + // Download to save config + await ynabPage.downloadConvertedFile(); + + // Verify config was saved with correct delimiter + const configs = await ynabPage.getLocalStorageConfigs(); + const configId = Object.keys(configs.configs)[0]; + const savedConfig = configs.configs[configId]; + + expect(savedConfig.chosenDelimiter).toBe(";"); + }); + }); + + test.describe("Saved configs management", () => { + test("shows saved configurations on landing page", async ({ page }) => { + // First, create a saved config + const filePath = path.resolve("test_files/chase_statement_2024.csv"); + await ynabPage.uploadFile(filePath); + + await ynabPage.setColumnMapping("Date", "Date"); + await ynabPage.setColumnMapping("Payee", "Description"); + + await ynabPage.downloadConvertedFile(); + + // Reload to go back to landing page + await page.reload(); + await ynabPage.page.waitForSelector(".show_on_load", { + state: "visible", + }); + + // Should show saved configs card + await expect(ynabPage.savedConfigsCard).toBeVisible(); + + // Should have at least one config + const count = await ynabPage.getSavedConfigsCount(); + expect(count).toBeGreaterThan(0); + }); + + test("can delete a saved configuration", async ({ page }) => { + // Create a saved config + const filePath = path.resolve("test_files/chase_statement_2024.csv"); + await ynabPage.uploadFile(filePath); + + await ynabPage.setColumnMapping("Date", "Date"); + await ynabPage.setColumnMapping("Payee", "Description"); + + await ynabPage.downloadConvertedFile(); + + // Reload to go back to landing page + await page.reload(); + await ynabPage.page.waitForSelector(".show_on_load", { + state: "visible", + }); + + // Get initial count + const initialCount = await ynabPage.getSavedConfigsCount(); + expect(initialCount).toBeGreaterThan(0); + + // Delete the config + await ynabPage.deleteConfigAt(0); + + // Wait for deletion to process + await page.waitForTimeout(500); + + // Count should be reduced + const finalCount = await ynabPage.getSavedConfigsCount(); + expect(finalCount).toBe(initialCount - 1); + }); + }); + + test.describe("Save with custom name", () => { + test("allows saving configuration with custom name via prompt", async ({ + page, + }) => { + // Upload a file + const filePath = path.resolve("test_files/chase_statement_2024.csv"); + await ynabPage.uploadFile(filePath); + + await ynabPage.setColumnMapping("Date", "Date"); + await ynabPage.setColumnMapping("Payee", "Description"); + + // Set up dialog handler BEFORE triggering the save + page.once("dialog", async (dialog) => { + await dialog.accept("My Custom Chase Config"); + }); + + // Click the dropdown toggle to show options + const dropdownToggle = ynabPage.saveConfigDropdown.locator( + ".dropdown-toggle-split", + ); + await dropdownToggle.click(); + + // Click "Save as..." option + const saveAsButton = page.locator('[data-testid="save-config-as"]'); + await saveAsButton.click(); + + // Wait for save to process + await page.waitForTimeout(500); + + // Verify config was saved with custom name + const configs = await ynabPage.getLocalStorageConfigs(); + const configId = Object.keys(configs.configs)[0]; + const savedConfig = configs.configs[configId]; + + expect(savedConfig.name).toBe("My Custom Chase Config"); + }); + }); +}); diff --git a/e2e/specs/column-mapping.spec.js b/e2e/specs/column-mapping.spec.js new file mode 100644 index 0000000..cae5180 --- /dev/null +++ b/e2e/specs/column-mapping.spec.js @@ -0,0 +1,254 @@ +import { test, expect } from "@playwright/test"; +import { YnabConverterPage } from "../pages/ynab-converter.page"; + +test.describe("Column Mapping", () => { + let ynabPage; + + test.beforeEach(async ({ page }) => { + ynabPage = new YnabConverterPage(page); + await ynabPage.goto(); + + // Upload a test CSV file + const csvContent = `Transaction Date,Description,Debit,Credit,Balance +2024-01-01,Grocery Store,50.00,,1000.00 +2024-01-02,Salary,,2000.00,3000.00 +2024-01-03,Electric Bill,150.00,,2850.00`; + + await page.evaluate((content) => { + const blob = new Blob([content], { type: "text/csv" }); + const file = new File([blob], "test-mapping.csv", { type: "text/csv" }); + const dt = new DataTransfer(); + dt.items.add(file); + + const fileInput = document.querySelector('[data-testid="file-input"]'); + fileInput.files = dt.files; + fileInput.dispatchEvent(new Event("change", { bubbles: true })); + }, csvContent); + + await ynabPage.toolWrapper.waitFor({ state: "visible" }); + }); + + test("displays column mapping dropdowns", async ({ page }) => { + const mappingSelects = await ynabPage.columnMappingSelects.count(); + expect(mappingSelects).toBeGreaterThan(0); + + // Check for YNAB column headers using test IDs + await expect( + page.locator('[data-testid="column-header-Date"]'), + ).toBeVisible(); + await expect( + page.locator('[data-testid="column-header-Payee"]'), + ).toBeVisible(); + await expect( + page.locator('[data-testid="column-header-Memo"]'), + ).toBeVisible(); + }); + + test("allows mapping CSV columns to YNAB format", async ({ page }) => { + // Map Date column + await ynabPage.setColumnMapping("Date", "Transaction Date"); + + // Map Payee column + await ynabPage.setColumnMapping("Payee", "Description"); + + // Verify preview updates + const previewData = await ynabPage.getPreviewData(); + expect(previewData.length).toBeGreaterThan(0); + + // First row should have date from Transaction Date column + expect(previewData[0][0]).toContain("2024-01-01"); + }); + + test("toggles between old and new YNAB format", async ({ page }) => { + // Check initial format using test IDs + const outflowExists = await page + .locator('[data-testid="column-header-Outflow"]') + .isVisible(); + const inflowExists = await page + .locator('[data-testid="column-header-Inflow"]') + .isVisible(); + + if (outflowExists && inflowExists) { + // Toggle to new format + await ynabPage.toggleFormatButton.click(); + + // Should now show Amount column + await expect( + page.locator('[data-testid="column-header-Amount"]'), + ).toBeVisible(); + await expect( + page.locator('[data-testid="column-header-Outflow"]'), + ).not.toBeVisible(); + await expect( + page.locator('[data-testid="column-header-Inflow"]'), + ).not.toBeVisible(); + + // Toggle back + await ynabPage.toggleFormatButton.click(); + await expect( + page.locator('[data-testid="column-header-Outflow"]'), + ).toBeVisible(); + await expect( + page.locator('[data-testid="column-header-Inflow"]'), + ).toBeVisible(); + } else { + // Started with new format, toggle to old + await ynabPage.toggleFormatButton.click(); + await expect( + page.locator('[data-testid="column-header-Outflow"]'), + ).toBeVisible(); + await expect( + page.locator('[data-testid="column-header-Inflow"]'), + ).toBeVisible(); + } + }); + + test("inverts transaction flows", async ({ page }) => { + // Map columns first + await ynabPage.setColumnMapping("Date", "Transaction Date"); + await ynabPage.setColumnMapping("Payee", "Description"); + // Map both Outflow and Inflow to the same field (Balance) to enable invert flows button + await ynabPage.setColumnMapping("Outflow", "Balance"); + await ynabPage.setColumnMapping("Inflow", "Balance"); + + // Get initial preview data + const initialData = await ynabPage.getPreviewData(); + + // Click invert flows button (should now be visible) + await ynabPage.invertFlowsButton.click(); + + // Get updated preview data + const invertedData = await ynabPage.getPreviewData(); + + // Data should be different after inversion + expect(invertedData).not.toEqual(initialData); + }); + + test("preserves column mappings when switching formats", async ({ page }) => { + // Set up mappings + await ynabPage.setColumnMapping("Date", "Transaction Date"); + await ynabPage.setColumnMapping("Payee", "Description"); + + // Get the selected values using test IDs + const dateMapping = await page + .locator('[data-testid="column-select-Date"]') + .inputValue(); + const payeeMapping = await page + .locator('[data-testid="column-select-Payee"]') + .inputValue(); + + // Toggle format + await ynabPage.toggleFormatButton.click(); + + // Check that Date and Payee mappings are preserved + const dateMappingAfter = await page + .locator('[data-testid="column-select-Date"]') + .inputValue(); + const payeeMappingAfter = await page + .locator('[data-testid="column-select-Payee"]') + .inputValue(); + + expect(dateMappingAfter).toBe(dateMapping); + expect(payeeMappingAfter).toBe(payeeMapping); + }); + + test("handles empty column mapping", async ({ page }) => { + // Leave some columns unmapped + await ynabPage.setColumnMapping("Date", "Transaction Date"); + // Leave Payee unmapped + + // Should still show preview + const previewData = await ynabPage.getPreviewData(); + expect(previewData.length).toBeGreaterThan(0); + + // Unmapped columns should be empty (dots may be visual placeholders via CSS) + expect(previewData[0][1].trim()).toBe(""); // Payee should be empty + }); + + test("inverts amount sign when using new YNAB format", async ({ page }) => { + // Switch to new format (Amount column) + const amountColumnExists = await page + .locator('[data-testid="column-header-Amount"]') + .isVisible(); + + if (!amountColumnExists) { + // Toggle to new format if not already there + await ynabPage.toggleFormatButton.click(); + await expect( + page.locator('[data-testid="column-header-Amount"]'), + ).toBeVisible(); + } + + // Map columns + await ynabPage.setColumnMapping("Date", "Transaction Date"); + await ynabPage.setColumnMapping("Payee", "Description"); + await ynabPage.setColumnMapping("Amount", "Balance"); + + // Get initial preview data + const initialData = await ynabPage.getPreviewData(); + const initialAmountColumn = initialData.map((row) => row[3]); // Amount is 4th column in new format + + // Invert amount button should be visible + await expect(ynabPage.invertAmountButton).toBeVisible(); + + // Click invert amount button + await ynabPage.invertAmountButton.click(); + + // Get updated preview data + const invertedData = await ynabPage.getPreviewData(); + const invertedAmountColumn = invertedData.map((row) => row[3]); + + // Data should be different after inversion + expect(invertedAmountColumn).not.toEqual(initialAmountColumn); + + // Verify signs are actually inverted + // Initial positive becomes negative, initial negative becomes positive + for (let i = 0; i < initialAmountColumn.length; i++) { + const initial = initialAmountColumn[i].trim(); + const inverted = invertedAmountColumn[i].trim(); + + if (initial && inverted) { + if (initial.startsWith("-")) { + // Negative becomes positive + expect(inverted.startsWith("-")).toBe(false); + } else { + // Positive becomes negative + expect(inverted.startsWith("-")).toBe(true); + } + } + } + }); + + test("invert amount button only visible in new YNAB format", async ({ + page, + }) => { + // Check if we're in old format (Inflow/Outflow) + const outflowExists = await page + .locator('[data-testid="column-header-Outflow"]') + .isVisible(); + + if (outflowExists) { + // In old format - invert amount button should NOT be visible + await expect(ynabPage.invertAmountButton).not.toBeVisible(); + + // Toggle to new format + await ynabPage.toggleFormatButton.click(); + + // Map Amount column to make invert amount button appear + await ynabPage.setColumnMapping("Amount", "Balance"); + + // Now invert amount button should be visible + await expect(ynabPage.invertAmountButton).toBeVisible(); + } else { + // Already in new format - invert amount button should be visible + await ynabPage.setColumnMapping("Amount", "Balance"); + await expect(ynabPage.invertAmountButton).toBeVisible(); + + // Toggle to old format + await ynabPage.toggleFormatButton.click(); + + // Now invert amount button should NOT be visible + await expect(ynabPage.invertAmountButton).not.toBeVisible(); + } + }); +}); diff --git a/e2e/specs/export-download.spec.js b/e2e/specs/export-download.spec.js new file mode 100644 index 0000000..08295fe --- /dev/null +++ b/e2e/specs/export-download.spec.js @@ -0,0 +1,159 @@ +import { test, expect } from "@playwright/test"; +import { YnabConverterPage } from "../pages/ynab-converter.page"; +import * as fs from "fs"; +import * as path from "path"; + +test.describe("Export and Download", () => { + let ynabPage; + + test.beforeEach(async ({ page }) => { + ynabPage = new YnabConverterPage(page); + await ynabPage.goto(); + + // Wait for FileUtils to be loaded by the page + await page.waitForFunction(() => window.FileUtils !== undefined); + + // Upload a test CSV file + const csvContent = `Date,Description,Amount +2024-01-01,Test Transaction 1,-50.00 +2024-01-02,Test Transaction 2,100.00 +2024-01-03,Test Transaction 3,-25.50`; + + await page.evaluate((content) => { + const blob = new Blob([content], { type: "text/csv" }); + const file = new File([blob], "test-export.csv", { type: "text/csv" }); + const dt = new DataTransfer(); + dt.items.add(file); + + const fileInput = document.querySelector('[data-testid="file-input"]'); + fileInput.files = dt.files; + fileInput.dispatchEvent(new Event("change", { bubbles: true })); + }, csvContent); + + await ynabPage.toolWrapper.waitFor({ state: "visible" }); + }); + + test("downloads converted YNAB file", async ({ page }) => { + // Map columns + await ynabPage.setColumnMapping("Date", "Date"); + await ynabPage.setColumnMapping("Payee", "Description"); + + // For new format with Amount column + const amountColumnExists = + (await page.locator('[data-testid="column-select-Amount"]').count()) > 0; + if (amountColumnExists) { + await ynabPage.setColumnMapping("Amount", "Amount"); + } else { + // Old format - need to handle Outflow/Inflow + // This would require more complex logic to split positive/negative + } + + // Download the file + const download = await ynabPage.downloadConvertedFile(); + + // Verify download + expect(download).toBeTruthy(); + + // Check filename format (should be ynab_data_YYYYMMDD.csv) + const filename = download.suggestedFilename(); + expect(filename).toMatch(/^ynab_data_\d{8}\.csv$/); + + // Save and verify content + const downloadPath = await download.path(); + if (downloadPath) { + const content = fs.readFileSync(downloadPath, "utf-8"); + + // Should be valid CSV + expect(content).toContain(","); + + // Should contain our data + expect(content).toContain("Test Transaction 1"); + expect(content).toContain("Test Transaction 2"); + } + }); + + test("download button appears after file upload", async ({ page }) => { + await expect(ynabPage.downloadButton).toBeVisible(); + }); + + test("generates correct filename with current date", async ({ page }) => { + const download = await ynabPage.downloadConvertedFile(); + const filename = download.suggestedFilename(); + + // Get current date in YYYYMMDD format + const today = new Date(); + const year = today.getFullYear(); + const month = String(today.getMonth() + 1).padStart(2, "0"); + const day = String(today.getDate()).padStart(2, "0"); + const expectedDate = `${year}${month}${day}`; + + expect(filename).toBe(`ynab_data_${expectedDate}.csv`); + }); + + test("exported file contains proper CSV format", async ({ page }) => { + // Set up mappings + await ynabPage.setColumnMapping("Date", "Date"); + await ynabPage.setColumnMapping("Payee", "Description"); + + const download = await ynabPage.downloadConvertedFile(); + const downloadPath = await download.path(); + + if (downloadPath) { + const content = fs.readFileSync(downloadPath, "utf-8"); + const lines = content.trim().split("\n"); + + // Should have header row + expect(lines.length).toBeGreaterThan(1); + + // Check header contains YNAB columns + const header = lines[0]; + expect(header).toContain("Date"); + expect(header).toContain("Payee"); + + // Check data rows + expect(lines.length).toBe(4); // Header + 3 data rows + } + }); + + test("handles Excel file export", async ({ page }) => { + // Upload an Excel file instead + await page.reload(); + await ynabPage.goto(); + + await ynabPage.uploadFile("test_files/test.xlsx"); + await ynabPage.toolWrapper.waitFor({ state: "visible" }); + + // Download should work the same way + const download = await ynabPage.downloadConvertedFile(); + expect(download).toBeTruthy(); + + // Should still export as CSV + const filename = download.suggestedFilename(); + expect(filename).toMatch(/\.csv$/); + }); + + test("exported data respects column mappings", async ({ page }) => { + // Create specific mappings + await ynabPage.setColumnMapping("Date", "Date"); + await ynabPage.setColumnMapping("Payee", "Description"); + await ynabPage.setColumnMapping("Memo", "Amount"); // Intentionally wrong mapping + + const download = await ynabPage.downloadConvertedFile(); + const downloadPath = await download.path(); + + if (downloadPath) { + const content = fs.readFileSync(downloadPath, "utf-8"); + + // The Amount values should appear in Memo column + expect(content).toContain("-50.00"); + expect(content).toContain("100.00"); + + // Parse CSV to verify structure + const lines = content.trim().split("\n"); + const dataRow = lines[1].split(","); + + // Memo column (index 2) should have amount value + expect(dataRow[2]).toMatch(/[\-\d\.]+/); + } + }); +}); diff --git a/e2e/specs/file-upload.spec.js b/e2e/specs/file-upload.spec.js new file mode 100644 index 0000000..128bf25 --- /dev/null +++ b/e2e/specs/file-upload.spec.js @@ -0,0 +1,221 @@ +import { test, expect } from "@playwright/test"; +import { YnabConverterPage } from "../pages/ynab-converter.page"; + +test.describe("File Upload", () => { + let ynabPage; + + test.beforeEach(async ({ page }) => { + ynabPage = new YnabConverterPage(page); + await ynabPage.goto(); + + // Wait for FileUtils to be loaded by the page + await page.waitForFunction(() => window.FileUtils !== undefined); + }); + + test("shows upload interface on load", async ({ page }) => { + await expect(ynabPage.uploadWrapper).toBeVisible(); + await expect(ynabPage.dropzone).toBeVisible(); + await expect(page.getByText("Drop CSV or Excel file")).toBeVisible(); + }); + + test("accepts CSV file upload", async ({ page }) => { + // Create a test CSV file content + const csvContent = `Date,Description,Amount +2024-01-01,Test Transaction,-50.00 +2024-01-02,Another Transaction,100.00`; + + // Create a file from string + await page.evaluate((content) => { + const blob = new Blob([content], { type: "text/csv" }); + const file = new File([blob], "test.csv", { type: "text/csv" }); + const dt = new DataTransfer(); + dt.items.add(file); + + const fileInput = document.querySelector('[data-testid="file-input"]'); + fileInput.files = dt.files; + fileInput.dispatchEvent(new Event("change", { bubbles: true })); + }, csvContent); + + await expect(ynabPage.toolWrapper).toBeVisible(); + await expect(ynabPage.dataTable).toBeVisible(); + await expect(ynabPage.settingsToggle).toBeVisible(); + }); + + // Test each Excel format + const excelFormats = [ + { ext: "xlsx", name: "Excel 2007+" }, + { ext: "xls", name: "Excel 97-2003" }, + { ext: "xlsm", name: "Excel with Macros" }, + { ext: "xlsb", name: "Binary Excel" }, + ]; + + for (const format of excelFormats) { + test(`uploads ${format.name} (${format.ext}) file successfully`, async ({ + page, + }) => { + const filePath = `test_files/test.${format.ext}`; + + await ynabPage.uploadFile(filePath); + + // Verify file was loaded + await expect(ynabPage.toolWrapper).toBeVisible({ timeout: 15000 }); + await expect(ynabPage.dataTable).toBeVisible(); + + // Verify file type badge shows correct format (wait longer for Excel processing) + await expect(ynabPage.fileTypeBadge).toBeVisible({ timeout: 15000 }); + const badgeText = await ynabPage.fileTypeBadge.textContent(); + expect(badgeText.toLowerCase()).toContain(format.ext); + }); + } + + test("shows worksheet selector for multi-sheet Excel files", async ({ + page, + }) => { + // Would need a multi-sheet test file + // For now, verify selector doesn't show for single-sheet files + await ynabPage.uploadFile("test_files/test.xlsx"); + + // Check if worksheet selector exists + const worksheetSelectorCount = await ynabPage.worksheetSelector.count(); + + // This test assumes test.xlsx has only one sheet + // Update when we have a multi-sheet test file + if (worksheetSelectorCount > 0) { + const worksheetOptions = await ynabPage.worksheetSelector + .locator("option") + .count(); + expect(worksheetOptions).toBeGreaterThan(0); + } + }); + + test("handles drag and drop file upload", async ({ page }) => { + // Create a test CSV for drag and drop + const csvContent = `Date,Payee,Amount +2024-01-01,Store A,-25.00 +2024-01-02,Store B,-30.00`; + + // Simulate drag and drop using test ID + await page.evaluate((content) => { + const dropzone = document.querySelector('[data-testid="dropzone"]'); + + // Create drag events + const dragEnterEvent = new DragEvent("dragenter", { + bubbles: true, + cancelable: true, + }); + + const dragOverEvent = new DragEvent("dragover", { + bubbles: true, + cancelable: true, + }); + + dropzone.dispatchEvent(dragEnterEvent); + dropzone.dispatchEvent(dragOverEvent); + + // Create file and drop event + const blob = new Blob([content], { type: "text/csv" }); + const file = new File([blob], "drag-test.csv", { type: "text/csv" }); + const dt = new DataTransfer(); + dt.items.add(file); + + const dropEvent = new DragEvent("drop", { + bubbles: true, + cancelable: true, + dataTransfer: dt, + }); + + dropzone.dispatchEvent(dropEvent); + }, csvContent); + + // Verify file was processed + await expect(ynabPage.toolWrapper).toBeVisible({ timeout: 5000 }); + }); + + test("shows error for invalid file types", async ({ page }) => { + // Create an invalid file type + await page.evaluate(() => { + const blob = new Blob(["invalid content"], { type: "text/plain" }); + const file = new File([blob], "test.txt", { type: "text/plain" }); + const dt = new DataTransfer(); + dt.items.add(file); + + const fileInput = document.querySelector('[data-testid="file-input"]'); + fileInput.files = dt.files; + fileInput.dispatchEvent(new Event("change", { bubbles: true })); + }); + + // Wait a moment for the app to process the file + await page.waitForTimeout(1000); + + // The app should either: + // 1. Show the tool wrapper (if it parsed as CSV) + // 2. Stay on upload screen (if it failed) + // 3. Show some error state + const toolWrapperVisible = await ynabPage.toolWrapper.isVisible(); + const uploadWrapperVisible = await ynabPage.uploadWrapper.isVisible(); + + // At least one wrapper should be visible (upload or tool) + // If both are hidden, that indicates an unexpected error state + expect(toolWrapperVisible || uploadWrapperVisible).toBeTruthy(); + }); + + test("configuration dropdown is accessible", async ({ page }) => { + await ynabPage.openConfigDropdown(); + + await expect(ynabPage.encodingSelect).toBeVisible(); + await expect(ynabPage.delimiterSelect).toBeVisible(); + await expect(ynabPage.startRowInput).toBeVisible(); + await expect(ynabPage.extraRowCheckbox).toBeVisible(); + }); + + test("settings panel appears after successful file upload", async ({ + page, + }) => { + // Upload a valid CSV file + const csvContent = `Date,Description,Amount +2024-01-01,Test Transaction,-25.00 +2024-01-02,Another Transaction,100.00`; + + await page.evaluate((content) => { + const blob = new Blob([content], { type: "text/csv" }); + const file = new File([blob], "settings-test.csv", { type: "text/csv" }); + const dt = new DataTransfer(); + dt.items.add(file); + + const fileInput = document.querySelector('[data-testid="file-input"]'); + fileInput.files = dt.files; + fileInput.dispatchEvent(new Event("change", { bubbles: true })); + }, csvContent); + + // Wait for file processing + await expect(ynabPage.toolWrapper).toBeVisible(); + + // Verify settings panel toggle is visible + await expect(ynabPage.settingsToggle).toBeVisible(); + await expect(ynabPage.settingsToggle).toContainText( + "File parsing settings", + ); + + // Settings content should be hidden initially + await expect(ynabPage.settingsContent).not.toBeVisible(); + + // Toggle should work + await ynabPage.toggleSettingsPanel(); + await expect(ynabPage.settingsContent).toBeVisible(); + + // Should contain the same settings as pre-upload dropdown + const settingsContainer = ynabPage.settingsContent; + await expect( + settingsContainer.locator('[data-testid="encoding-select"]'), + ).toBeVisible(); + await expect( + settingsContainer.locator('[data-testid="delimiter-select"]'), + ).toBeVisible(); + await expect( + settingsContainer.locator('[data-testid="start-row-input"]'), + ).toBeVisible(); + await expect( + settingsContainer.locator('[data-testid="extra-row-checkbox"]'), + ).toBeVisible(); + }); +}); diff --git a/e2e/specs/settings-panel.spec.js b/e2e/specs/settings-panel.spec.js new file mode 100644 index 0000000..0473139 --- /dev/null +++ b/e2e/specs/settings-panel.spec.js @@ -0,0 +1,222 @@ +import { test, expect } from "@playwright/test"; +import { YnabConverterPage } from "../pages/ynab-converter.page"; + +test.describe("Settings Panel", () => { + let ynabPage; + + test.beforeEach(async ({ page }) => { + ynabPage = new YnabConverterPage(page); + await ynabPage.goto(); + + // Wait for FileUtils to be loaded + await page.waitForFunction(() => window.FileUtils !== undefined); + + // Upload a test CSV file to enable the settings panel + const csvContent = `Date,Description,Amount +2024-01-01,Grocery Store,-50.00 +2024-01-02,Salary,2000.00 +2024-01-03,Coffee Shop,-5.00`; + + await page.evaluate((content) => { + const blob = new Blob([content], { type: "text/csv" }); + const file = new File([blob], "test.csv", { type: "text/csv" }); + const dt = new DataTransfer(); + dt.items.add(file); + + const fileInput = document.querySelector('[data-testid="file-input"]'); + fileInput.files = dt.files; + fileInput.dispatchEvent(new Event("change", { bubbles: true })); + }, csvContent); + + await ynabPage.toolWrapper.waitFor({ state: "visible" }); + }); + + test("settings panel should be visible after file upload", async ({ + page, + }) => { + await expect(ynabPage.settingsToggle).toBeVisible(); + await expect(ynabPage.settingsToggle).toHaveText("File parsing settings"); + }); + + test("should toggle settings panel visibility", async ({ page }) => { + // Settings panel should be hidden by default + await expect(ynabPage.settingsContent).not.toBeVisible(); + + // Click to open + await ynabPage.toggleSettingsPanel(); + await expect(ynabPage.settingsContent).toBeVisible(); + + // Click to close + await ynabPage.toggleSettingsPanel(); + await expect(ynabPage.settingsContent).not.toBeVisible(); + }); + + test("should re-parse file when encoding changes", async ({ page }) => { + // Get initial preview data + const initialData = await ynabPage.getPreviewData(); + + // Change encoding + await ynabPage.changeSettingAfterUpload("encoding", "ISO-8859-1"); + + // Wait for re-parse + await page.waitForTimeout(1000); + + // Verify the encoding select in settings panel has the new value + const encodingSelect = ynabPage.settingsContent.locator( + '[data-testid="encoding-select"]', + ); + await expect(encodingSelect).toHaveValue("ISO-8859-1"); + }); + + test("should re-parse file when delimiter changes", async ({ page }) => { + // Upload a semicolon-delimited file + const csvContent = `Date;Description;Amount +2024-01-01;Store;-50.00 +2024-01-02;Income;1000.00`; + + await ynabPage.reloadButton.click(); + await ynabPage.uploadWrapper.waitFor({ state: "visible" }); + + await page.evaluate((content) => { + const blob = new Blob([content], { type: "text/csv" }); + const file = new File([blob], "semicolon.csv", { type: "text/csv" }); + const dt = new DataTransfer(); + dt.items.add(file); + + const fileInput = document.querySelector('[data-testid="file-input"]'); + fileInput.files = dt.files; + fileInput.dispatchEvent(new Event("change", { bubbles: true })); + }, csvContent); + + await ynabPage.toolWrapper.waitFor({ state: "visible" }); + + // Change delimiter to semicolon + await ynabPage.changeSettingAfterUpload("delimiter", ";"); + + // Verify data is parsed correctly + const previewData = await ynabPage.getPreviewData(); + expect(previewData.length).toBeGreaterThan(0); + }); + + test("should re-parse file when start row changes", async ({ page }) => { + // Upload file with header rows + const csvContent = `Bank Statement +Generated on 2024-01-01 +Date,Description,Amount +2024-01-01,Store,-50.00 +2024-01-02,Income,1000.00`; + + await ynabPage.reloadButton.click(); + await ynabPage.uploadWrapper.waitFor({ state: "visible" }); + + await page.evaluate((content) => { + const blob = new Blob([content], { type: "text/csv" }); + const file = new File([blob], "header-rows.csv", { type: "text/csv" }); + const dt = new DataTransfer(); + dt.items.add(file); + + const fileInput = document.querySelector('[data-testid="file-input"]'); + fileInput.files = dt.files; + fileInput.dispatchEvent(new Event("change", { bubbles: true })); + }, csvContent); + + await ynabPage.toolWrapper.waitFor({ state: "visible" }); + + // Change start row to 3 to skip header lines + await ynabPage.changeSettingAfterUpload("startRow", 3); + + // Verify the input has the new value + const startRowInput = ynabPage.settingsContent.locator( + '[data-testid="start-row-input"]', + ); + await expect(startRowInput).toHaveValue("3"); + }); + + test("should toggle extra row checkbox", async ({ page }) => { + await ynabPage.changeSettingAfterUpload("extraRow", true); + + const checkbox = ynabPage.settingsContent.locator( + '[data-testid="extra-row-checkbox"]', + ); + await expect(checkbox).toBeChecked(); + + // Toggle again + await ynabPage.changeSettingAfterUpload("extraRow", false); + await expect(checkbox).not.toBeChecked(); + }); + + test("worksheet selector should appear for multi-sheet Excel files", async ({ + page, + }) => { + // This test would require a multi-sheet Excel file fixture + // For now, we'll verify that worksheet selector is not visible for CSV files + await ynabPage.toggleSettingsPanel(); + + const worksheetSelect = ynabPage.settingsContent.locator( + '[data-testid="worksheet-select"]', + ); + await expect(worksheetSelect).not.toBeVisible(); + }); + + test("settings should persist when toggling panel", async ({ page }) => { + // Change some settings + await ynabPage.changeSettingAfterUpload("delimiter", ";"); + await ynabPage.changeSettingAfterUpload("startRow", 2); + + // Close panel + await ynabPage.toggleSettingsPanel(); + await expect(ynabPage.settingsContent).not.toBeVisible(); + + // Reopen panel + await ynabPage.toggleSettingsPanel(); + await expect(ynabPage.settingsContent).toBeVisible(); + + // Verify settings are still the same + const delimiterSelect = ynabPage.settingsContent.locator( + '[data-testid="delimiter-select"]', + ); + const startRowInput = ynabPage.settingsContent.locator( + '[data-testid="start-row-input"]', + ); + + await expect(delimiterSelect).toHaveValue(";"); + await expect(startRowInput).toHaveValue("2"); + }); + + test("settings changes should update preview data", async ({ page }) => { + // Upload CSV with headers + const csvContent = `Transaction Date,Description,Debit,Credit +01/01/2024,Store,50.00, +02/01/2024,Salary,,2000.00`; + + await ynabPage.reloadButton.click(); + await ynabPage.uploadWrapper.waitFor({ state: "visible" }); + + await page.evaluate((content) => { + const blob = new Blob([content], { type: "text/csv" }); + const file = new File([blob], "transactions.csv", { type: "text/csv" }); + const dt = new DataTransfer(); + dt.items.add(file); + + const fileInput = document.querySelector('[data-testid="file-input"]'); + fileInput.files = dt.files; + fileInput.dispatchEvent(new Event("change", { bubbles: true })); + }, csvContent); + + await ynabPage.toolWrapper.waitFor({ state: "visible" }); + + // Get initial row count + const initialRows = await ynabPage.getPreviewData(); + const initialRowCount = initialRows.length; + + // Change start row to skip header + await ynabPage.changeSettingAfterUpload("startRow", 2); + + // Wait for re-parse and get new data + await page.waitForTimeout(1000); + const updatedRows = await ynabPage.getPreviewData(); + + // Should have different data after skipping header + expect(updatedRows).not.toEqual(initialRows); + }); +}); diff --git a/index.html b/index.html index 3da1e66..fdb307d 100644 --- a/index.html +++ b/index.html @@ -1,4 +1,4 @@ - + @@ -8,7 +8,7 @@ href="https://stackpath.bootstrapcdn.com/bootstrap/4.4.1/css/bootstrap.min.css" integrity="sha384-Vkoo8x4CGsO3+Hhxv8T/Q5PaXtkKtu6ug5TOeNV6gBiFeWPGFN9MuhOf23Q9Ifjh" crossorigin="anonymous" - > + /> - + - + + + + - - - +
-
+
-
+
Load a different file
-
+
Save YNAB Data
@@ -66,97 +90,199 @@
-
-
+
+

-
-
Drop file
+
+
Drop CSV or Excel file
Or choose
-
-
+ + +
+
+
+ Saved Configurations +
+
+
    +
  • +
    + {{config.name}} +
    + + Used {{config.useCount}} time{{config.useCount !== 1 ? 's' : + ''}} + +
    +
    + + +
    +
  • +
+
-
+
+ +
+ +
+
+
+ +
+
+
+
+ + +
+ + + Configuration auto-applied! + Settings loaded from "{{matchedConfig.config.name}}" + {{matchedConfig.confidence}}% match +
+ + +
+ + + Found similar configuration: "{{matchedConfig.config.name}}" + {{matchedConfig.confidence}}% match + +
+
@@ -164,27 +290,104 @@ ng-show="ynab_cols.indexOf('Inflow') != -1 && ynab_map['Inflow'] == ynab_map['Outflow']" class="btn btn-outline-secondary" ng-click="invert_flows()" + data-testid="invert-flows-button" > Invert flows
+
+ Invert amount +
+
+ Fix dates +
Toggle column format
+ +
+ + + +
-

YNAB Data (first 10 rows)

+

+ YNAB Data (first 10 rows) + + + {{FileUtils.getFileType(filename)}} + +

- +
- - + @@ -209,10 +419,11 @@

YNAB Data (first 10 rows)

-
+
- Imported CSV (first 10 rows) + Imported {{FileUtils.getFileType(filename)}} + (first 10 rows)
+ +
+
{{row[col]}}
diff --git a/jest.config.js b/jest.config.js index 25ad6a0..eaf4511 100644 --- a/jest.config.js +++ b/jest.config.js @@ -1,13 +1,11 @@ module.exports = { - testEnvironment: 'jsdom', + testEnvironment: "jsdom", transform: { - '^.+\\.js$': 'babel-jest', + "^.+\\.js$": "babel-jest", }, - moduleFileExtensions: ['js'], - testMatch: ['**/__tests__/**/*.js', '**/?(*.)+(spec|test).js'], - collectCoverageFrom: [ - 'src/**/*.js', - '!src/**/*.min.js', - ], - setupFilesAfterEnv: ['/jest.setup.js'], -}; \ No newline at end of file + moduleFileExtensions: ["js"], + testMatch: ["**/__tests__/**/*.js", "**/?(*.)+(spec|test).js"], + testPathIgnorePatterns: ["/e2e/"], + collectCoverageFrom: ["src/**/*.js", "!src/**/*.min.js"], + setupFilesAfterEnv: ["/jest.setup.js"], +}; diff --git a/jest.setup.js b/jest.setup.js index 6e66b9f..29d2c2e 100644 --- a/jest.setup.js +++ b/jest.setup.js @@ -12,4 +12,7 @@ global.FileReader = jest.fn(() => ({ readAsBinaryString: jest.fn(), addEventListener: jest.fn(), result: null, -})); \ No newline at end of file +})); + +// Load FileUtils module for all tests +require("./src/file_utils.js"); diff --git a/package-lock.json b/package-lock.json index 6a0e054..6221fe4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7,17 +7,22 @@ "": { "name": "ynab-csv", "version": "2.0.0", + "hasInstallScript": true, "license": "MIT", "dependencies": { "http-server": "^14.1.1" }, "devDependencies": { "@babel/preset-env": "^7.28.0", + "@playwright/test": "^1.54.1", "babel-jest": "^30.0.5", "browser-sync": "^3.0.2", + "husky": "^9.1.7", "jest": "^30.0.5", "jest-environment-jsdom": "^30.0.5", - "jsdom": "^26.1.0" + "jsdom": "^26.1.0", + "lint-staged": "^16.1.2", + "prettier": "^3.6.2" } }, "node_modules/@ampproject/remapping": { @@ -2599,6 +2604,22 @@ "url": "https://opencollective.com/pkgr" } }, + "node_modules/@playwright/test": { + "version": "1.54.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.54.1.tgz", + "integrity": "sha512-FS8hQ12acieG2dYSksmLOF7BNxnVf2afRJdCuM1eMSxj6QTSE6G4InGF7oApGgDb65MX7AwMVlIkpru0yZA4Xw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.54.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/@sinclair/typebox": { "version": "0.34.38", "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.38.tgz", @@ -3658,6 +3679,93 @@ "dev": true, "license": "MIT" }, + "node_modules/cli-cursor": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-5.0.0.tgz", + "integrity": "sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==", + "dev": true, + "license": "MIT", + "dependencies": { + "restore-cursor": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cli-truncate": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/cli-truncate/-/cli-truncate-4.0.0.tgz", + "integrity": "sha512-nPdaFdQ0h/GEigbPClz11D0v/ZJEwxmeVZGeMo3Z5StPtUTkA9o1lD6QwoirYiSDzbcwn2XcjwmCp68W1IS4TA==", + "dev": true, + "license": "MIT", + "dependencies": { + "slice-ansi": "^5.0.0", + "string-width": "^7.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cli-truncate/node_modules/ansi-regex": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.1.0.tgz", + "integrity": "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/cli-truncate/node_modules/emoji-regex": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.4.0.tgz", + "integrity": "sha512-EC+0oUMY1Rqm4O6LLrgjtYDvcVYTy7chDnM4Q7030tP4Kwj3u/pR6gP9ygnp2CJMK5Gq+9Q2oqmrFJAz01DXjw==", + "dev": true, + "license": "MIT" + }, + "node_modules/cli-truncate/node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cli-truncate/node_modules/strip-ansi": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz", + "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, "node_modules/cliui": { "version": "8.0.1", "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", @@ -3709,6 +3817,13 @@ "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", "license": "MIT" }, + "node_modules/colorette": { + "version": "2.0.20", + "resolved": "https://registry.npmjs.org/colorette/-/colorette-2.0.20.tgz", + "integrity": "sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==", + "dev": true, + "license": "MIT" + }, "node_modules/commander": { "version": "2.20.3", "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", @@ -4128,6 +4243,19 @@ "url": "https://github.com/fb55/entities?sponsor=1" } }, + "node_modules/environment": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/environment/-/environment-1.1.0.tgz", + "integrity": "sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/error-ex": { "version": "1.3.2", "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.2.tgz", @@ -4467,6 +4595,19 @@ "node": "6.* || 8.* || >= 10.*" } }, + "node_modules/get-east-asian-width": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.3.0.tgz", + "integrity": "sha512-vpeMIQKxczTD/0s2CdEWHcb0eeJe6TFjxb+J5xgX7hScxqrGuyjmv4c1D4A/gelKfyox0gJJwIHF+fLjeaM8kQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/get-intrinsic": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", @@ -4823,6 +4964,22 @@ "node": ">=10.17.0" } }, + "node_modules/husky": { + "version": "9.1.7", + "resolved": "https://registry.npmjs.org/husky/-/husky-9.1.7.tgz", + "integrity": "sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA==", + "dev": true, + "license": "MIT", + "bin": { + "husky": "bin.js" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/typicode" + } + }, "node_modules/iconv-lite": { "version": "0.4.24", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", @@ -5962,6 +6119,19 @@ "node": ">=6" } }, + "node_modules/lilconfig": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", + "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antonk52" + } + }, "node_modules/limiter": { "version": "1.1.5", "resolved": "https://registry.npmjs.org/limiter/-/limiter-1.1.5.tgz", @@ -5975,6 +6145,192 @@ "dev": true, "license": "MIT" }, + "node_modules/lint-staged": { + "version": "16.1.2", + "resolved": "https://registry.npmjs.org/lint-staged/-/lint-staged-16.1.2.tgz", + "integrity": "sha512-sQKw2Si2g9KUZNY3XNvRuDq4UJqpHwF0/FQzZR2M7I5MvtpWvibikCjUVJzZdGE0ByurEl3KQNvsGetd1ty1/Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^5.4.1", + "commander": "^14.0.0", + "debug": "^4.4.1", + "lilconfig": "^3.1.3", + "listr2": "^8.3.3", + "micromatch": "^4.0.8", + "nano-spawn": "^1.0.2", + "pidtree": "^0.6.0", + "string-argv": "^0.3.2", + "yaml": "^2.8.0" + }, + "bin": { + "lint-staged": "bin/lint-staged.js" + }, + "engines": { + "node": ">=20.17" + }, + "funding": { + "url": "https://opencollective.com/lint-staged" + } + }, + "node_modules/lint-staged/node_modules/chalk": { + "version": "5.4.1", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.4.1.tgz", + "integrity": "sha512-zgVZuo2WcZgfUEmsn6eO3kINexW8RAE4maiQ8QNs8CtpPCSyMiYsULR3HQYkm3w8FIA3SberyMJMSldGsW+U3w==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/lint-staged/node_modules/commander": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-14.0.0.tgz", + "integrity": "sha512-2uM9rYjPvyq39NwLRqaiLtWHyDC1FvryJDa2ATTVims5YAS4PupsEQsDvP14FqhFr0P49CYDugi59xaxJlTXRA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/lint-staged/node_modules/debug": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz", + "integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/lint-staged/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/listr2": { + "version": "8.3.3", + "resolved": "https://registry.npmjs.org/listr2/-/listr2-8.3.3.tgz", + "integrity": "sha512-LWzX2KsqcB1wqQ4AHgYb4RsDXauQiqhjLk+6hjbaeHG4zpjjVAB6wC/gz6X0l+Du1cN3pUB5ZlrvTbhGSNnUQQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "cli-truncate": "^4.0.0", + "colorette": "^2.0.20", + "eventemitter3": "^5.0.1", + "log-update": "^6.1.0", + "rfdc": "^1.4.1", + "wrap-ansi": "^9.0.0" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/listr2/node_modules/ansi-regex": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.1.0.tgz", + "integrity": "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/listr2/node_modules/ansi-styles": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz", + "integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/listr2/node_modules/emoji-regex": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.4.0.tgz", + "integrity": "sha512-EC+0oUMY1Rqm4O6LLrgjtYDvcVYTy7chDnM4Q7030tP4Kwj3u/pR6gP9ygnp2CJMK5Gq+9Q2oqmrFJAz01DXjw==", + "dev": true, + "license": "MIT" + }, + "node_modules/listr2/node_modules/eventemitter3": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.1.tgz", + "integrity": "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==", + "dev": true, + "license": "MIT" + }, + "node_modules/listr2/node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/listr2/node_modules/strip-ansi": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz", + "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/listr2/node_modules/wrap-ansi": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.0.tgz", + "integrity": "sha512-G8ura3S+3Z2G+mkgNRq8dqaFZAuxfsxpBB8OCTGRTCtp+l/v9nbFNmCUP1BZMts3G1142MsZfn6eeUKrr4PD1Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.2.1", + "string-width": "^7.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, "node_modules/locate-path": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", @@ -6009,6 +6365,160 @@ "dev": true, "license": "MIT" }, + "node_modules/log-update": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/log-update/-/log-update-6.1.0.tgz", + "integrity": "sha512-9ie8ItPR6tjY5uYJh8K/Zrv/RMZ5VOlOWvtZdEHYSTFKZfIBPQa9tOAEeAWhd+AnIneLJ22w5fjOYtoutpWq5w==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-escapes": "^7.0.0", + "cli-cursor": "^5.0.0", + "slice-ansi": "^7.1.0", + "strip-ansi": "^7.1.0", + "wrap-ansi": "^9.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/log-update/node_modules/ansi-escapes": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-7.0.0.tgz", + "integrity": "sha512-GdYO7a61mR0fOlAsvC9/rIHf7L96sBc6dEWzeOu+KAea5bZyQRPIpojrVoI4AXGJS/ycu/fBTdLrUkA4ODrvjw==", + "dev": true, + "license": "MIT", + "dependencies": { + "environment": "^1.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/log-update/node_modules/ansi-regex": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.1.0.tgz", + "integrity": "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/log-update/node_modules/ansi-styles": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz", + "integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/log-update/node_modules/emoji-regex": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.4.0.tgz", + "integrity": "sha512-EC+0oUMY1Rqm4O6LLrgjtYDvcVYTy7chDnM4Q7030tP4Kwj3u/pR6gP9ygnp2CJMK5Gq+9Q2oqmrFJAz01DXjw==", + "dev": true, + "license": "MIT" + }, + "node_modules/log-update/node_modules/is-fullwidth-code-point": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-5.0.0.tgz", + "integrity": "sha512-OVa3u9kkBbw7b8Xw5F9P+D/T9X+Z4+JruYVNapTjPYZYUznQ5YfWeFkOj606XYYW8yugTfC8Pj0hYqvi4ryAhA==", + "dev": true, + "license": "MIT", + "dependencies": { + "get-east-asian-width": "^1.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/log-update/node_modules/slice-ansi": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-7.1.0.tgz", + "integrity": "sha512-bSiSngZ/jWeX93BqeIAbImyTbEihizcwNjFoRUIY/T1wWQsfsm2Vw1agPKylXvQTU7iASGdHhyqRlqQzfz+Htg==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.2.1", + "is-fullwidth-code-point": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/slice-ansi?sponsor=1" + } + }, + "node_modules/log-update/node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/log-update/node_modules/strip-ansi": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz", + "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/log-update/node_modules/wrap-ansi": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.0.tgz", + "integrity": "sha512-G8ura3S+3Z2G+mkgNRq8dqaFZAuxfsxpBB8OCTGRTCtp+l/v9nbFNmCUP1BZMts3G1142MsZfn6eeUKrr4PD1Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.2.1", + "string-width": "^7.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, "node_modules/lru-cache": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", @@ -6133,6 +6643,19 @@ "node": ">=6" } }, + "node_modules/mimic-function": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/mimic-function/-/mimic-function-5.0.1.tgz", + "integrity": "sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/minimatch": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", @@ -6179,6 +6702,19 @@ "dev": true, "license": "MIT" }, + "node_modules/nano-spawn": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/nano-spawn/-/nano-spawn-1.0.2.tgz", + "integrity": "sha512-21t+ozMQDAL/UGgQVBbZ/xXvNO10++ZPuTmKRO8k9V3AClVRht49ahtDjfY8l1q6nSHOrE5ASfthzH3ol6R/hg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.17" + }, + "funding": { + "url": "https://github.com/sindresorhus/nano-spawn?sponsor=1" + } + }, "node_modules/napi-postinstall": { "version": "0.3.2", "resolved": "https://registry.npmjs.org/napi-postinstall/-/napi-postinstall-0.3.2.tgz", @@ -6524,6 +7060,19 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/pidtree": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/pidtree/-/pidtree-0.6.0.tgz", + "integrity": "sha512-eG2dWTVw5bzqGRztnHExczNxt5VGsE6OwTeCG3fdUf9KBsZzO3R5OIIIzWR+iZA0NtZ+RDVdaoE2dK1cn6jH4g==", + "dev": true, + "license": "MIT", + "bin": { + "pidtree": "bin/pidtree.js" + }, + "engines": { + "node": ">=0.10" + } + }, "node_modules/pirates": { "version": "4.0.7", "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", @@ -6547,6 +7096,53 @@ "node": ">=8" } }, + "node_modules/playwright": { + "version": "1.54.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.54.1.tgz", + "integrity": "sha512-peWpSwIBmSLi6aW2auvrUtf2DqY16YYcCMO8rTVx486jKmDTJg7UAhyrraP98GB8BoPURZP8+nxO7TSd4cPr5g==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.54.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.54.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.54.1.tgz", + "integrity": "sha512-Nbjs2zjj0htNhzgiy5wu+3w09YetDx5pkrpI/kZotDlDUaYk0HVA5xrBVPdow4SAUIlhgKcJeJg4GRKW6xHusA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/playwright/node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/portfinder": { "version": "1.0.37", "resolved": "https://registry.npmjs.org/portfinder/-/portfinder-1.0.37.tgz", @@ -6608,6 +7204,22 @@ "lodash": "^4.17.14" } }, + "node_modules/prettier": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.6.2.tgz", + "integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, "node_modules/pretty-format": { "version": "30.0.5", "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.0.5.tgz", @@ -6868,6 +7480,46 @@ "node": ">= 0.8.0" } }, + "node_modules/restore-cursor": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-5.1.0.tgz", + "integrity": "sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==", + "dev": true, + "license": "MIT", + "dependencies": { + "onetime": "^7.0.0", + "signal-exit": "^4.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/restore-cursor/node_modules/onetime": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-7.0.0.tgz", + "integrity": "sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "mimic-function": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/rfdc": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz", + "integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==", + "dev": true, + "license": "MIT" + }, "node_modules/rrweb-cssom": { "version": "0.8.0", "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.8.0.tgz", @@ -7280,6 +7932,49 @@ "node": ">=8" } }, + "node_modules/slice-ansi": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-5.0.0.tgz", + "integrity": "sha512-FC+lgizVPfie0kkhqUScwRu1O/lF6NOgJmlCgK+/LYxDCTk8sGelYaHDhFcDN+Sn3Cv+3VSa4Byeo+IMCzpMgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.0.0", + "is-fullwidth-code-point": "^4.0.0" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/slice-ansi?sponsor=1" + } + }, + "node_modules/slice-ansi/node_modules/ansi-styles": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz", + "integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/slice-ansi/node_modules/is-fullwidth-code-point": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-4.0.0.tgz", + "integrity": "sha512-O4L094N2/dZ7xqVdrXhh9r1KODPJpFms8B5sGdJLPy664AgvXsreZUyCQQNItZRDlYug4xStLjNp/sz3HvBowQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/socket.io": { "version": "4.8.1", "resolved": "https://registry.npmjs.org/socket.io/-/socket.io-4.8.1.tgz", @@ -7508,6 +8203,16 @@ "node": ">= 0.10.0" } }, + "node_modules/string-argv": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/string-argv/-/string-argv-0.3.2.tgz", + "integrity": "sha512-aqD2Q0144Z+/RqG52NeHEkZauTAUWJO8c6yTftGJKO3Tja5tUgIfmIl6kExvhtxSDP7fXB6DvzkfMpCd/F3G+Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.6.19" + } + }, "node_modules/string-length": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/string-length/-/string-length-4.0.2.tgz", @@ -8241,6 +8946,19 @@ "dev": true, "license": "ISC" }, + "node_modules/yaml": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.0.tgz", + "integrity": "sha512-4lLa/EcQCB0cJkyts+FpIRx5G/llPxfP6VQU5KByHEhLxY3IJCH0f0Hy1MHI8sClTvsIb8qwRJ6R/ZdlDJ/leQ==", + "dev": true, + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + } + }, "node_modules/yargs": { "version": "17.7.2", "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", diff --git a/package.json b/package.json index 7e063a6..3a373cd 100644 --- a/package.json +++ b/package.json @@ -7,8 +7,19 @@ "test": "jest", "test:watch": "jest --watch", "test:coverage": "jest --coverage", + "test:e2e": "playwright test", + "test:e2e:headed": "playwright test --headed", + "test:e2e:debug": "playwright test --debug", + "test:e2e:ui": "playwright test --ui", + "test:e2e:chromium": "playwright test --project=chromium", + "test:e2e:firefox": "playwright test --project=firefox", + "test:e2e:webkit": "playwright test --project=webkit", "start": "http-server --port 3000 -o", - "bs": "browser-sync . -w" + "bs": "browser-sync . -w", + "format": "prettier --write .", + "format:check": "prettier --check .", + "prepare": "husky", + "postinstall": "husky" }, "repository": { "type": "git", @@ -29,10 +40,17 @@ }, "devDependencies": { "@babel/preset-env": "^7.28.0", + "@playwright/test": "^1.54.1", "babel-jest": "^30.0.5", "browser-sync": "^3.0.2", + "husky": "^9.1.7", "jest": "^30.0.5", "jest-environment-jsdom": "^30.0.5", - "jsdom": "^26.1.0" + "jsdom": "^26.1.0", + "lint-staged": "^16.1.2", + "prettier": "^3.6.2" + }, + "lint-staged": { + "*.{js,json,md,html,css,yml,yaml}": "prettier --write" } } diff --git a/playwright.config.js b/playwright.config.js new file mode 100644 index 0000000..803251c --- /dev/null +++ b/playwright.config.js @@ -0,0 +1,38 @@ +import { defineConfig, devices } from "@playwright/test"; + +export default defineConfig({ + testDir: "./e2e", + fullyParallel: true, + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 2 : 0, + workers: process.env.CI ? 1 : undefined, + reporter: "html", + use: { + baseURL: "http://localhost:3000", + trace: "on-first-retry", + screenshot: "only-on-failure", + video: "retain-on-failure", + }, + + projects: [ + { + name: "chromium", + use: { ...devices["Desktop Chrome"] }, + }, + { + name: "firefox", + use: { ...devices["Desktop Firefox"] }, + }, + { + name: "webkit", + use: { ...devices["Desktop Safari"] }, + }, + ], + + webServer: { + command: "npm start", + port: 3000, + reuseExistingServer: !process.env.CI, + timeout: 120 * 1000, + }, +}); diff --git a/src/app.js b/src/app.js index 1ab03ba..650eed4 100644 --- a/src/app.js +++ b/src/app.js @@ -1,21 +1,49 @@ // see http://stackoverflow.com/questions/2897619/using-html5-javascript-to-generate-and-save-a-file // see http://stackoverflow.com/questions/18662404/download-lengthy-data-as-a-csv-file var encodings = [ - "UTF-8", "IBM866", "ISO-8859-1", "ISO-8859-2", "ISO-8859-3", "ISO-8859-4", "ISO-8859-5", - "ISO-8859-6", "ISO-8859-7", "ISO-8859-8", "ISO-8859-8-I", "ISO-8859-10", - "ISO-8859-13", "ISO-8859-14", "ISO-8859-15", "ISO-8859-16", "KOI8-R", - "KOI8-U", "macintosh", "windows-874", "windows-1250", "windows-1251", - "windows-1252", "windows-1253", "windows-1254", "windows-1255", - "windows-1256", "windows-1257", "windows-1258", "x-mac-cyrillic", "GBK", - "gb18030", "Big5", "EUC-JP", "ISO-2022-JP", "Shift_JIS", "EUC-KR", - "replacement", "UTF-16BE", "UTF-16LE", "x-user-defined" -] -var delimiters = [ - "auto", - ",", - ";", - "|" -] + "UTF-8", + "IBM866", + "ISO-8859-1", + "ISO-8859-2", + "ISO-8859-3", + "ISO-8859-4", + "ISO-8859-5", + "ISO-8859-6", + "ISO-8859-7", + "ISO-8859-8", + "ISO-8859-8-I", + "ISO-8859-10", + "ISO-8859-13", + "ISO-8859-14", + "ISO-8859-15", + "ISO-8859-16", + "KOI8-R", + "KOI8-U", + "macintosh", + "windows-874", + "windows-1250", + "windows-1251", + "windows-1252", + "windows-1253", + "windows-1254", + "windows-1255", + "windows-1256", + "windows-1257", + "windows-1258", + "x-mac-cyrillic", + "GBK", + "gb18030", + "Big5", + "EUC-JP", + "ISO-2022-JP", + "Shift_JIS", + "EUC-KR", + "replacement", + "UTF-16BE", + "UTF-16LE", + "x-user-defined", +]; +var delimiters = ["auto", ",", ";", "|"]; var old_ynab_cols = ["Date", "Payee", "Memo", "Outflow", "Inflow"]; var new_ynab_cols = ["Date", "Payee", "Memo", "Amount"]; var defaultProfile = { @@ -27,44 +55,177 @@ var defaultProfile = { chosenEncoding: "UTF-8", chosenDelimiter: "auto", startAtRow: 1, - extraRow: false + extraRow: false, }; var defaultProfiles = { - "default profile": defaultProfile + "default profile": defaultProfile, }; Date.prototype.yyyymmdd = function () { var mm = this.getMonth() + 1; // getMonth() is zero-based var dd = this.getDate(); - return [this.getFullYear(), - (mm > 9 ? '' : '0') + mm, - (dd > 9 ? '' : '0') + dd - ].join(''); + return [ + this.getFullYear(), + (mm > 9 ? "" : "0") + mm, + (dd > 9 ? "" : "0") + dd, + ].join(""); }; angular.element(document).ready(function () { + // Shared helper functions for file processing + + function processFile(file, scope, targetProperty, attributes) { + var reader = new FileReader(); + + reader.onload = function (loadEvent) { + scope.$apply(function () { + var fileData = FileUtils.createDataWrapper( + loadEvent.target.result, + file.name, + ); + scope.filename = file.name; + scope[targetProperty] = fileData; + }); + }; + + reader.onerror = function (error) { + console.error("FileReader error:", error); + }; + if (FileUtils.isExcelFile(file.name)) { + var method = FileUtils.getExcelReadingMethod(file.name); + if (method === "arrayBuffer") { + reader.readAsArrayBuffer(file); + } else { + reader.readAsBinaryString(file); + } + } else { + reader.readAsText(file, attributes.encoding); + } + } + angular.module("app", []); angular.module("app").directive("fileread", [ function () { return { scope: { - fileread: "=" + fileread: "=", + filename: "=", }, link: function (scope, element, attributes) { - return element.bind("change", function (changeEvent) { - var reader; - reader = new FileReader(); - reader.onload = function (loadEvent) { - return scope.$apply(function () { - scope.fileread = loadEvent.target.result; - }); - }; - reader.readAsText(changeEvent.target.files[0], attributes.encoding); - }); - } + try { + element.bind("change", function (changeEvent) { + var file = changeEvent.target.files[0]; + if (!file) return; + + processFile(file, scope, "fileread", attributes); + }); + } catch (error) { + console.error("Error in fileread directive:", error); + } + }, }; - } + }, + ]); + angular.module("app").directive("fileParsingSettings", [ + function () { + return { + restrict: "E", + scope: { + file: "=", + profiles: "=", + profileName: "=", + onEncodingChange: "&", + onDelimiterChange: "&", + onStartRowChange: "&", + onExtraRowChange: "&", + onProfileChange: "&", + showProfiles: "=", + worksheetNames: "=", + selectedWorksheet: "=", + onWorksheetChange: "&", + }, + template: + '
' + + '
' + + ' ' + + ' " + + "
" + + '
' + + ' ' + + ' " + + "
" + + '
' + + ' ' + + ' " + + "
" + + '
' + + ' ' + + ' ' + + "
" + + '
' + + ' ' + + ' " + + "
" + + '
' + + ' ' + + ' " + + "
" + + "
", + link: function (scope, element, attrs) { + // Generate unique ID for form elements to avoid conflicts + scope.uniqueId = "fps-" + Math.random().toString(36).substr(2, 9); + }, + }; + }, ]); angular.module("app").directive("dropzone", [ function () { @@ -73,7 +234,8 @@ angular.element(document).ready(function () { replace: true, template: '
', scope: { - dropzone: "=" + dropzone: "=", + filename: "=", }, link: function (scope, element, attributes) { element.bind("dragenter", function (event) { @@ -86,7 +248,8 @@ angular.element(document).ready(function () { event.preventDefault(); event.stopPropagation(); var dataTransfer; - dataTransfer = (event.dataTransfer || event.originalEvent.dataTransfer) + dataTransfer = + event.dataTransfer || event.originalEvent.dataTransfer; efct = dataTransfer.effectAllowed; dataTransfer.dropEffect = "move" === efct || "linkMove" === efct ? "move" : "copy"; @@ -96,153 +259,774 @@ angular.element(document).ready(function () { event.preventDefault(); }); element.bind("drop", function (event) { - var reader; element.removeClass("dragging"); event.preventDefault(); event.stopPropagation(); - reader = new FileReader(); - reader.onload = function (loadEvent) { - scope.$apply(function () { - scope.dropzone = loadEvent.target.result; - }); - }; - file = (event.dataTransfer || event.originalEvent.dataTransfer).files[0]; - reader.readAsText(file, attributes.encoding); + + var file = (event.dataTransfer || event.originalEvent.dataTransfer) + .files[0]; + if (!file) return; + + processFile(file, scope, "dropzone", attributes); }); element.bind("paste", function (event) { - var items = (event.clipboardData || event.originalEvent.clipboardData).items; + var items = ( + event.clipboardData || event.originalEvent.clipboardData + ).items; + var data; for (var i = 0; i < items.length; i++) { - if (items[i].type == 'text/plain') { + if (items[i].type == "text/plain") { data = items[i]; break; } } if (!data) return; - data.getAsString(function(text) { + data.getAsString(function (text) { scope.$apply(function () { scope.dropzone = text; }); }); }); - } + }, }; - } + }, ]); // Application code - angular.module("app") - .config(function($locationProvider) { - $locationProvider.html5Mode({ - enabled: true, - requireBase: false, - }).hashPrefix('!'); - }) - .controller("ParseController", function ($scope, $location) { - $scope.angular_loaded = true; - - $scope.setInitialScopeState = function () { - $scope.profileName = ($location.search().profile || localStorage.getItem('profileName') || 'default profile').toLowerCase(); - $scope.profiles = JSON.parse(localStorage.getItem('profiles')) || defaultProfiles; - if(!$scope.profiles[$scope.profileName]) { - $scope.profiles[$scope.profileName] = defaultProfile; - } - $scope.profile = $scope.profiles[$scope.profileName]; - $scope.ynab_cols = $scope.profile.columnFormat; - $scope.data = {}; - $scope.ynab_map = $scope.profile.chosenColumns - $scope.inverted_outflow = false; - $scope.file = { - encodings: encodings, - delimiters: delimiters, - chosenEncoding: $scope.profile.chosenEncoding || "UTF-8", - chosenDelimiter: $scope.profile.chosenDelimiter || "auto", - startAtRow: $scope.profile.startAtRow, - extraRow: $scope.profile.extraRow || false - }; - $scope.data_object = new DataObject(); - } + angular + .module("app") + .config(function ($locationProvider) { + $locationProvider + .html5Mode({ + enabled: true, + requireBase: false, + }) + .hashPrefix("!"); + }) + .controller("ParseController", function ($scope, $location) { + $scope.angular_loaded = true; - $scope.setInitialScopeState(); - $scope.profileChosen = function (profileName) { - $location.search('profile', profileName); - $scope.profile = $scope.profiles[$scope.profileName]; - $scope.ynab_cols = $scope.profile.columnFormat; - $scope.ynab_map = $scope.profile.chosenColumns; - localStorage.setItem('profileName', profileName); - }; - $scope.encodingChosen = function (encoding) { - $scope.profile.chosenEncoding = encoding; - localStorage.setItem('profiles', JSON.stringify($scope.profiles)); - }; - $scope.delimiterChosen = function (delimiter) { - $scope.profile.chosenDelimiter = delimiter; - localStorage.setItem('profiles', JSON.stringify($scope.profiles)); - }; - $scope.startRowSet = function (startAtRow) { - $scope.profile.startAtRow = startAtRow; - localStorage.setItem('profiles', JSON.stringify($scope.profiles)); - }; - $scope.extraRowSet = function (extraRow) { - $scope.profile.extraRow = extraRow; - localStorage.setItem('profiles', JSON.stringify($scope.profiles)); - }; - $scope.nonDefaultProfilesExist = function() { - return Object.keys($scope.profiles).length > 1; - }; - $scope.toggleColumnFormat = function () { - if ($scope.ynab_cols == new_ynab_cols) { - $scope.ynab_cols = old_ynab_cols; - } else { - $scope.ynab_cols = new_ynab_cols; - } - $scope.profile.columnFormat = $scope.ynab_cols - localStorage.setItem('profiles', JSON.stringify($scope.profiles)); - }; - $scope.$watch("data.source", function (newValue, oldValue) { - if (newValue && newValue.length > 0) { - if ($scope.file.chosenDelimiter == "auto") { - $scope.data_object.parseCsv(newValue, $scope.file.chosenEncoding, $scope.file.startAtRow, $scope.profile.extraRow); + $scope.setInitialScopeState = function () { + $scope.profileName = ( + $location.search().profile || + localStorage.getItem("profileName") || + "default profile" + ).toLowerCase(); + $scope.profiles = + JSON.parse(localStorage.getItem("profiles")) || defaultProfiles; + if (!$scope.profiles[$scope.profileName]) { + $scope.profiles[$scope.profileName] = defaultProfile; + } + $scope.profile = $scope.profiles[$scope.profileName]; + $scope.ynab_cols = $scope.profile.columnFormat; + $scope.data = {}; + $scope.ynab_map = $scope.profile.chosenColumns; + $scope.inverted_outflow = false; + $scope.inverted_amount = false; + $scope.fix_dates = false; + $scope.file = { + encodings: encodings, + delimiters: delimiters, + chosenEncoding: $scope.profile.chosenEncoding || "UTF-8", + chosenDelimiter: $scope.profile.chosenDelimiter || "auto", + startAtRow: $scope.profile.startAtRow, + extraRow: $scope.profile.extraRow || false, + selectedWorksheet: 0, // Use index as source of truth + }; + $scope.data_object = new DataObject(); + $scope.filename = null; + + // Auto-matching configuration state + $scope.matchedConfig = null; + $scope.autoApplied = false; + $scope.savedConfigs = ConfigMatcher.getAllConfigurations(); + }; + + $scope.setInitialScopeState(); + + // Make FileUtils available in templates + $scope.FileUtils = FileUtils; + + $scope.profileChosen = function (profileName) { + $location.search("profile", profileName); + $scope.profile = $scope.profiles[$scope.profileName]; + $scope.ynab_cols = $scope.profile.columnFormat; + $scope.ynab_map = $scope.profile.chosenColumns; + localStorage.setItem("profileName", profileName); + }; + $scope.encodingChosen = function (encoding) { + $scope.profile.chosenEncoding = encoding; + localStorage.setItem("profiles", JSON.stringify($scope.profiles)); + }; + $scope.delimiterChosen = function (delimiter) { + $scope.profile.chosenDelimiter = delimiter; + localStorage.setItem("profiles", JSON.stringify($scope.profiles)); + }; + $scope.startRowSet = function (startAtRow) { + $scope.profile.startAtRow = startAtRow; + localStorage.setItem("profiles", JSON.stringify($scope.profiles)); + }; + $scope.extraRowSet = function (extraRow) { + $scope.profile.extraRow = extraRow; + localStorage.setItem("profiles", JSON.stringify($scope.profiles)); + }; + $scope.reparseFile = function () { + // Only reparse if we have data loaded + if ($scope.data.source && $scope.data.source.data) { + try { + if ( + FileUtils.isExcelFile($scope.currentFilename || $scope.filename) + ) { + // Re-parse Excel file with current settings + $scope.data_object.parseExcel( + $scope.data.source.data, + $scope.currentFilename || $scope.filename, + $scope.file.chosenEncoding, + $scope.file.startAtRow, + $scope.profile.extraRow, + $scope.file.chosenDelimiter == "auto" + ? null + : $scope.file.chosenDelimiter, + $scope.file.selectedWorksheet || 0, + ); + } else { + // Re-parse CSV file with current settings + if ($scope.file.chosenDelimiter == "auto") { + $scope.data_object.parseCsv( + $scope.data.source.data, + $scope.file.chosenEncoding, + $scope.file.startAtRow, + $scope.profile.extraRow, + ); + } else { + $scope.data_object.parseCsv( + $scope.data.source.data, + $scope.file.chosenEncoding, + $scope.file.startAtRow, + $scope.profile.extraRow, + $scope.file.chosenDelimiter, + ); + } + } + + // Update preview + $scope.preview = $scope.data_object.converted_json( + 10, + $scope.ynab_cols, + $scope.ynab_map, + $scope.inverted_outflow, + $scope.inverted_amount, + $scope.fix_dates, + ); + + // Save settings to profile + $scope.encodingChosen($scope.file.chosenEncoding); + $scope.delimiterChosen($scope.file.chosenDelimiter); + $scope.startRowSet($scope.file.startAtRow); + $scope.extraRowSet($scope.file.extraRow); + } catch (error) { + console.error("Error re-parsing file:", error); + alert("Error re-parsing file: " + error.message); + } + } + }; + $scope.nonDefaultProfilesExist = function () { + return Object.keys($scope.profiles).length > 1; + }; + $scope.toggleColumnFormat = function () { + if ($scope.ynab_cols == new_ynab_cols) { + $scope.ynab_cols = old_ynab_cols; } else { - $scope.data_object.parseCsv(newValue, $scope.file.chosenEncoding, $scope.file.startAtRow, $scope.profile.extraRow, $scope.file.chosenDelimiter); + $scope.ynab_cols = new_ynab_cols; } - $scope.preview = $scope.data_object.converted_json(10, $scope.ynab_cols, $scope.ynab_map, $scope.inverted_outflow); - } - }); - $scope.$watch("inverted_outflow", function (newValue, oldValue) { - if (newValue != oldValue) { - $scope.preview = $scope.data_object.converted_json(10, $scope.ynab_cols, $scope.ynab_map, $scope.inverted_outflow); - } + $scope.profile.columnFormat = $scope.ynab_cols; + localStorage.setItem("profiles", JSON.stringify($scope.profiles)); + }; + + // ============================================ + // Auto-matching configuration methods + // ============================================ + + // Try to auto-apply a matching configuration + $scope.tryAutoApplyConfig = function () { + if (!$scope.data || !$scope.data.source || !$scope.data.source.data) { + return; + } + + // Use findMatchingConfigWithStartRow to try multiple header rows + var match = ConfigMatcher.findMatchingConfigWithStartRow( + $scope.data.source.data, + $scope.filename, + $scope.file.chosenDelimiter === "auto" + ? null + : $scope.file.chosenDelimiter, + ); + + if (match && match.confidence >= 60) { + $scope.matchedConfig = match; + + // Auto-apply if confidence is high enough + if (match.confidence >= 80) { + // Re-parse if startAtRow differs from current setting + var detectedRow = + match.detectedStartAtRow || match.config.startAtRow; + if (detectedRow && detectedRow !== $scope.file.startAtRow) { + $scope.file.startAtRow = detectedRow; + $scope.reparseFile(); + } + $scope.applyConfig(match.config); + $scope.autoApplied = true; + } + } + }; + + // Try to auto-apply config for Excel files using already-parsed headers + // (Excel binary data can't be pre-parsed like CSV text) + $scope.tryAutoApplyConfigForExcel = function () { + if (!$scope.data_object || !$scope.data_object.fields) { + return; + } + + var headers = $scope.data_object.fields(); + if (!headers || headers.length === 0) { + return; + } + + // Try matching with current parsed headers + var match = ConfigMatcher.findMatchingConfig(headers, $scope.filename); + + if (match && match.confidence >= 60) { + $scope.matchedConfig = match; + + // Auto-apply if confidence is high enough + if (match.confidence >= 80) { + var configStartAtRow = match.config.startAtRow || 1; + + // If the matched config has a different startAtRow, we need to re-parse + if (configStartAtRow !== $scope.file.startAtRow) { + $scope.file.startAtRow = configStartAtRow; + $scope.reparseFile(); + } + + $scope.applyConfig(match.config); + $scope.autoApplied = true; + return; + } + } + + // No high-confidence match with current startAtRow + // Try other saved startAtRow values + var allConfigs = ConfigMatcher.getAllConfigurations(); + var triedRows = new Set([$scope.file.startAtRow]); + + for (var configId in allConfigs) { + var config = allConfigs[configId]; + var configRow = config.startAtRow || 1; + + if (triedRows.has(configRow)) { + continue; + } + triedRows.add(configRow); + + // Re-parse with this startAtRow + $scope.file.startAtRow = configRow; + $scope.reparseFile(); + + // Try matching again with new headers + headers = $scope.data_object.fields(); + match = ConfigMatcher.findMatchingConfig(headers, $scope.filename); + + if (match && match.confidence >= 80) { + $scope.matchedConfig = match; + $scope.applyConfig(match.config); + $scope.autoApplied = true; + return; + } + } + + // No match found - reset to profile default if we changed it + var profileStartAtRow = $scope.profile.startAtRow || 1; + if ($scope.file.startAtRow !== profileStartAtRow) { + $scope.file.startAtRow = profileStartAtRow; + $scope.reparseFile(); + } + }; + + // Apply a configuration to current settings + $scope.applyConfig = function (config) { + if (!config) return; + + // Apply column format + if (config.columnFormat) { + $scope.ynab_cols = config.columnFormat; + } + + // Apply column mappings + if (config.chosenColumns) { + $scope.ynab_map = angular.copy(config.chosenColumns); + } + + // Apply file settings + if (config.chosenEncoding) { + $scope.file.chosenEncoding = config.chosenEncoding; + } + if (config.chosenDelimiter) { + $scope.file.chosenDelimiter = config.chosenDelimiter; + } + if (config.startAtRow) { + $scope.file.startAtRow = config.startAtRow; + } + if (typeof config.extraRow !== "undefined") { + $scope.file.extraRow = config.extraRow; + } + if (typeof config.invertedOutflow !== "undefined") { + $scope.inverted_outflow = config.invertedOutflow; + } + if (typeof config.invertedAmount !== "undefined") { + $scope.inverted_amount = config.invertedAmount; + } + if (typeof config.fixDates !== "undefined") { + $scope.fix_dates = config.fixDates; + } + + // Update preview + $scope.preview = $scope.data_object.converted_json( + 10, + $scope.ynab_cols, + $scope.ynab_map, + $scope.inverted_outflow, + $scope.inverted_amount, + $scope.fix_dates, + ); + + // Increment usage count + if ($scope.matchedConfig && $scope.matchedConfig.configId) { + ConfigMatcher.incrementUsageCount($scope.matchedConfig.configId); + } + }; + + // Save current settings as a configuration + $scope.saveCurrentConfig = function (name) { + if (!$scope.data_object || !$scope.data_object.base_json) { + return null; + } + + var headers = $scope.data_object.fields(); + var settings = { + columnFormat: $scope.ynab_cols, + chosenColumns: $scope.ynab_map, + chosenEncoding: $scope.file.chosenEncoding, + chosenDelimiter: $scope.file.chosenDelimiter, + startAtRow: $scope.file.startAtRow, + extraRow: $scope.file.extraRow, + invertedOutflow: $scope.inverted_outflow, + invertedAmount: $scope.inverted_amount, + fixDates: $scope.fix_dates, + }; + + var configId = ConfigMatcher.saveConfiguration( + headers, + $scope.filename, + settings, + name, + ); + + if (configId) { + $scope.matchedConfig = { + configId: configId, + matchType: "exact", + confidence: 100, + config: ConfigMatcher.getConfiguration(configId), + }; + $scope.refreshSavedConfigs(); + } + + return configId; + }; + + // Update the currently matched configuration + $scope.updateMatchedConfig = function () { + if (!$scope.matchedConfig || !$scope.matchedConfig.configId) { + return false; + } + + var result = ConfigMatcher.updateConfiguration( + $scope.matchedConfig.configId, + { + columnFormat: $scope.ynab_cols, + chosenColumns: $scope.ynab_map, + chosenEncoding: $scope.file.chosenEncoding, + chosenDelimiter: $scope.file.chosenDelimiter, + startAtRow: $scope.file.startAtRow, + extraRow: $scope.file.extraRow, + invertedOutflow: $scope.inverted_outflow, + invertedAmount: $scope.inverted_amount, + fixDates: $scope.fix_dates, + }, + ); + + if (result) { + $scope.matchedConfig.config = ConfigMatcher.getConfiguration( + $scope.matchedConfig.configId, + ); + $scope.refreshSavedConfigs(); + } + + return result; + }; + + // Refresh the saved configurations list + $scope.refreshSavedConfigs = function () { + $scope.savedConfigs = ConfigMatcher.getAllConfigurations(); + }; + + // Delete a saved configuration + $scope.deleteConfig = function (configId) { + if (ConfigMatcher.deleteConfiguration(configId)) { + if ( + $scope.matchedConfig && + $scope.matchedConfig.configId === configId + ) { + $scope.matchedConfig = null; + $scope.autoApplied = false; + } + $scope.refreshSavedConfigs(); + } + }; + + // Rename a saved configuration + $scope.renameConfig = function (configId, newName) { + if (ConfigMatcher.renameConfiguration(configId, newName)) { + if ( + $scope.matchedConfig && + $scope.matchedConfig.configId === configId + ) { + $scope.matchedConfig.config.name = newName; + } + $scope.refreshSavedConfigs(); + } + }; + + // Save config with user-provided name (shows prompt) + $scope.saveConfigWithName = function ($event) { + if ($event) $event.preventDefault(); + + var name = prompt( + "Enter a name for this configuration:", + $scope.matchedConfig + ? $scope.matchedConfig.config.name + : $scope.filename || "My Configuration", + ); + + if (name) { + $scope.saveCurrentConfig(name); + } + }; + + // Prompt to rename an existing configuration + $scope.promptRenameConfig = function (configId) { + var config = ConfigMatcher.getConfiguration(configId); + if (!config) return; + + var newName = prompt("Enter a new name:", config.name); + if (newName && newName !== config.name) { + $scope.renameConfig(configId, newName); + } + }; + + // Dismiss the auto-apply notification + $scope.dismissAutoApply = function () { + $scope.autoApplied = false; + }; + + // Check if any saved configs exist + $scope.hasSavedConfigs = function () { + return Object.keys($scope.savedConfigs).length > 0; + }; + + $scope.$watch("data.source", function (newValue, oldValue) { + if (newValue && newValue.data && newValue.filename) { + try { + // Store filename for later use in worksheet switching + $scope.currentFilename = newValue.filename; + $scope.filename = newValue.filename; + + // Reset auto-match state first + $scope.matchedConfig = null; + $scope.autoApplied = false; + + // Use profile defaults as baseline for matching, but preserve current + // file settings if no match is found + var profileStartAtRow = $scope.profile.startAtRow || 1; + var profileDelimiter = $scope.profile.chosenDelimiter || "auto"; + + // Try to find matching config BEFORE parsing (handles different startAtRow) + // Only for CSV files - Excel requires parsing first + if (!$scope.data_object.isExcelFile(newValue.filename)) { + var match = ConfigMatcher.findMatchingConfigWithStartRow( + newValue.data, + newValue.filename, + profileDelimiter === "auto" ? null : profileDelimiter, + ); + + if (match && match.confidence >= 80) { + $scope.matchedConfig = match; + // Use detected settings from matching config + $scope.file.startAtRow = + match.detectedStartAtRow || + match.config.startAtRow || + profileStartAtRow; + $scope.file.chosenDelimiter = + match.config.chosenDelimiter || profileDelimiter; + $scope.file.chosenEncoding = + match.config.chosenEncoding || $scope.file.chosenEncoding; + } else { + // No match found - reset to profile defaults for fresh file + $scope.file.startAtRow = profileStartAtRow; + // Keep current delimiter if explicitly set, otherwise use profile + if ( + $scope.file.chosenDelimiter === "auto" || + !$scope.file.chosenDelimiter + ) { + $scope.file.chosenDelimiter = profileDelimiter; + } + } + } + + // Process file based on type + if ($scope.data_object.isExcelFile(newValue.filename)) { + // For Excel files, reset to profile defaults BEFORE parsing + // (we can't pre-detect headers from binary Excel data) + $scope.file.startAtRow = profileStartAtRow; + $scope.file.chosenDelimiter = profileDelimiter; + + // Parse as Excel file with profile defaults + $scope.data_object.parseExcel( + newValue.data, + newValue.filename, + $scope.file.chosenEncoding, + $scope.file.startAtRow, + $scope.profile.extraRow, + $scope.file.chosenDelimiter == "auto" + ? null + : $scope.file.chosenDelimiter, + 0, // default to first worksheet + ); + + // Initialize worksheet selection for multi-sheet Excel files + if ( + $scope.data_object.worksheetNames && + $scope.data_object.worksheetNames.length > 0 + ) { + $scope.file.selectedWorksheet = 0; // Default to first worksheet (index 0) + $scope.$evalAsync(); + } + + // For Excel files, try auto-matching after parsing using parsed headers + $scope.tryAutoApplyConfigForExcel(); + } else { + // Parse as CSV file with detected settings + if ($scope.file.chosenDelimiter == "auto") { + $scope.data_object.parseCsv( + newValue.data, + $scope.file.chosenEncoding, + $scope.file.startAtRow, + $scope.profile.extraRow, + ); + } else { + $scope.data_object.parseCsv( + newValue.data, + $scope.file.chosenEncoding, + $scope.file.startAtRow, + $scope.profile.extraRow, + $scope.file.chosenDelimiter, + ); + } + + // Apply matched config after parsing (for CSV) + if ( + $scope.matchedConfig && + $scope.matchedConfig.confidence >= 80 + ) { + $scope.applyConfig($scope.matchedConfig.config); + $scope.autoApplied = true; + } + } + + // Auto-detect short year dates after file load + if ($scope.ynab_map && $scope.ynab_map.Date) { + if ($scope.data_object.hasShortYearDates($scope.ynab_map.Date)) { + $scope.fix_dates = true; + } + } + + $scope.preview = $scope.data_object.converted_json( + 10, + $scope.ynab_cols, + $scope.ynab_map, + $scope.inverted_outflow, + $scope.inverted_amount, + $scope.fix_dates, + ); + } catch (error) { + console.error("Error parsing file:", error); + alert("Error parsing file: " + error.message); + } + } + }); + $scope.$watch("inverted_outflow", function (newValue, oldValue) { + if (newValue != oldValue) { + $scope.preview = $scope.data_object.converted_json( + 10, + $scope.ynab_cols, + $scope.ynab_map, + $scope.inverted_outflow, + $scope.inverted_amount, + $scope.fix_dates, + ); + } + }); + $scope.$watch("inverted_amount", function (newValue, oldValue) { + if (newValue != oldValue) { + $scope.preview = $scope.data_object.converted_json( + 10, + $scope.ynab_cols, + $scope.ynab_map, + $scope.inverted_outflow, + $scope.inverted_amount, + $scope.fix_dates, + ); + } + }); + $scope.$watch("fix_dates", function (newValue, oldValue) { + if (newValue != oldValue) { + $scope.preview = $scope.data_object.converted_json( + 10, + $scope.ynab_cols, + $scope.ynab_map, + $scope.inverted_outflow, + $scope.inverted_amount, + $scope.fix_dates, + ); + } + }); + $scope.$watch( + "ynab_map", + function (newValue, oldValue) { + $scope.profile.chosenColumns = newValue; + localStorage.setItem("profiles", JSON.stringify($scope.profiles)); + // Auto-detect short year dates when Date mapping changes + if ( + newValue && + newValue.Date && + (!oldValue || newValue.Date !== oldValue.Date) + ) { + if ( + $scope.data_object.hasShortYearDates && + $scope.data_object.hasShortYearDates(newValue.Date) + ) { + $scope.fix_dates = true; + } + } + $scope.preview = $scope.data_object.converted_json( + 10, + $scope.ynab_cols, + newValue, + $scope.inverted_outflow, + $scope.inverted_amount, + $scope.fix_dates, + ); + }, + true, + ); + $scope.csvString = function () { + return $scope.data_object.converted_csv( + null, + $scope.ynab_cols, + $scope.ynab_map, + $scope.inverted_outflow, + $scope.inverted_amount, + $scope.fix_dates, + ); + }; + $scope.reloadApp = function () { + $scope.setInitialScopeState(); + }; + $scope.invert_flows = function () { + $scope.inverted_outflow = !$scope.inverted_outflow; + }; + $scope.invert_amount = function () { + $scope.inverted_amount = !$scope.inverted_amount; + }; + $scope.toggle_fix_dates = function () { + $scope.fix_dates = !$scope.fix_dates; + }; + + // Handle worksheet selection for Excel files + $scope.worksheetChosen = function (worksheetIndex) { + if ( + $scope.currentFilename && + $scope.data.source && + FileUtils.isExcelFile($scope.currentFilename) + ) { + try { + // Convert to number if it's a string (from ng-value) + var index = + typeof worksheetIndex === "string" + ? parseInt(worksheetIndex, 10) + : worksheetIndex; + + // Re-parse the Excel file with the selected worksheet + $scope.data_object.parseExcel( + $scope.data.source.data, + $scope.currentFilename, + $scope.file.chosenEncoding, + $scope.file.startAtRow, + $scope.profile.extraRow, + $scope.file.chosenDelimiter == "auto" + ? null + : $scope.file.chosenDelimiter, + index, + ); + + $scope.preview = $scope.data_object.converted_json( + 10, + $scope.ynab_cols, + $scope.ynab_map, + $scope.inverted_outflow, + $scope.inverted_amount, + $scope.fix_dates, + ); + $scope.$evalAsync(); + } catch (error) { + console.error("Error switching worksheet:", error); + alert("Error switching worksheet: " + error.message); + } + } + }; + $scope.downloadFile = function () { + // Auto-save configuration on download + if ($scope.data_object && $scope.data_object.base_json) { + if ($scope.matchedConfig) { + // Update existing configuration + $scope.updateMatchedConfig(); + } else { + // Save as new configuration + $scope.saveCurrentConfig(); + } + } + + var a; + var date = new Date(); + a = document.createElement("a"); + a.href = + "data:attachment/csv;base64," + + btoa(unescape(encodeURIComponent($scope.csvString()))); + a.target = "_blank"; + a.download = `ynab_data_${date.yyyymmdd()}.csv`; + document.body.appendChild(a); + a.click(); + }; }); - $scope.$watch( - "ynab_map", - function (newValue, oldValue) { - $scope.profile.chosenColumns = newValue; - localStorage.setItem('profiles', JSON.stringify($scope.profiles)); - $scope.preview = $scope.data_object.converted_json(10, $scope.ynab_cols, newValue, $scope.inverted_outflow); - }, - true - ); - $scope.csvString = function () { - return $scope.data_object.converted_csv(null, $scope.ynab_cols, $scope.ynab_map, $scope.inverted_outflow); - }; - $scope.reloadApp = function () { - $scope.setInitialScopeState(); - } - $scope.invert_flows = function () { - $scope.inverted_outflow = !$scope.inverted_outflow; - } - $scope.downloadFile = function () { - var a; - var date = new Date(); - a = document.createElement("a"); - a.href = - "data:attachment/csv;base64," + - btoa(unescape(encodeURIComponent($scope.csvString()))); - a.target = "_blank"; - a.download = `ynab_data_${date.yyyymmdd()}.csv`; - document.body.appendChild(a); - a.click(); - }; - }); angular.bootstrap(document, ["app"]); }); diff --git a/src/config_matcher.js b/src/config_matcher.js new file mode 100644 index 0000000..ea9a3c3 --- /dev/null +++ b/src/config_matcher.js @@ -0,0 +1,626 @@ +/** + * ConfigMatcher - Auto-matching configuration system for YNAB-CSV + * + * Stores and matches file configurations based on headers and filename patterns. + * When users upload files, this module attempts to find matching saved configurations + * and auto-apply column mappings. + */ +var ConfigMatcher = (function () { + "use strict"; + + var STORAGE_KEY = "knownConfigurations"; + + // ============================================ + // Storage helpers + // ============================================ + + function getStorageData() { + var stored = localStorage.getItem(STORAGE_KEY); + if (!stored) { + return { configs: {} }; + } + try { + var parsed = JSON.parse(stored); + return parsed && parsed.configs ? parsed : { configs: {} }; + } catch (e) { + console.warn("ConfigMatcher: Failed to parse stored configs", e); + return { configs: {} }; + } + } + + function saveStorageData(data) { + try { + localStorage.setItem(STORAGE_KEY, JSON.stringify(data)); + return true; + } catch (e) { + console.warn("ConfigMatcher: Failed to save configs", e); + return false; + } + } + + // ============================================ + // Fingerprint generation + // ============================================ + + /** + * Generate a simple hash from a string + * @param {string} str - Input string + * @returns {string} Hash string + */ + function simpleHash(str) { + var hash = 0; + for (var i = 0; i < str.length; i++) { + var char = str.charCodeAt(i); + hash = (hash << 5) - hash + char; + hash = hash & hash; // Convert to 32bit integer + } + return "fp_" + Math.abs(hash).toString(36); + } + + /** + * Generate a fingerprint from column headers + * Headers are normalized (lowercase, trimmed) and sorted for consistent matching + * + * @param {string[]} headers - Array of column header names + * @returns {string|null} Fingerprint string or null if invalid input + */ + function generateFingerprint(headers) { + if (!headers || !Array.isArray(headers) || headers.length === 0) { + return null; + } + + // Normalize: lowercase, trim, filter empty, sort alphabetically + var normalized = headers + .map(function (h) { + return String(h || "") + .toLowerCase() + .trim(); + }) + .filter(function (h) { + return h.length > 0; + }) + .sort() + .join("|"); + + if (normalized.length === 0) { + return null; + } + + return simpleHash(normalized); + } + + // ============================================ + // Filename pattern extraction + // ============================================ + + /** + * Extract meaningful patterns from a filename + * Removes dates, numbers, extensions, and splits into words + * + * @param {string} filename - File name + * @returns {string[]} Array of extracted patterns + */ + function extractFilenamePatterns(filename) { + if (!filename || typeof filename !== "string") { + return []; + } + + // Remove extension + var base = filename.replace(/\.(csv|xlsx?|xlsm|xlsb)$/i, ""); + + // Remove dates and long numbers (4+ digits) + base = base.replace(/[-_]?\d{4,}/g, ""); + + // Remove (1), (2), etc. + base = base.replace(/\(\d+\)/g, ""); + + // Convert to lowercase + base = base.toLowerCase(); + + // Split into words on common separators + var patterns = base + .split(/[-_\s.]+/) + .filter(function (p) { + return p.length > 2; // Skip very short patterns + }) + .slice(0, 5); // Max 5 patterns + + return patterns; + } + + // ============================================ + // Header extraction from raw content + // ============================================ + + /** + * Extract headers from raw CSV content at a specific row + * + * @param {string} content - Raw CSV content + * @param {number} startAtRow - Row number where headers are (1-indexed) + * @param {string} delimiter - Delimiter to use (or auto-detect) + * @returns {string[]} Array of header names + */ + function extractHeadersFromContent(content, startAtRow, delimiter) { + if (!content || typeof content !== "string") { + return []; + } + + startAtRow = startAtRow || 1; + + // Use PapaParse with same transformHeader logic as data_object.js + // This ensures headers match what gets saved in configs + var existingHeaders = []; + var config = { + header: true, + skipEmptyLines: true, + preview: 1, // Only parse first row for headers + beforeFirstChunk: function (chunk) { + var rows = chunk.split("\n"); + var startIndex = startAtRow - 1; + rows = rows.slice(startIndex); + return rows.join("\n"); + }, + transformHeader: function (header) { + if (header.trim().length === 0) { + header = "Unnamed column"; + } + if (existingHeaders.indexOf(header) !== -1) { + var newHeader = header; + var counter = 0; + while (existingHeaders.indexOf(newHeader) !== -1) { + counter++; + newHeader = header + " (" + counter + ")"; + } + header = newHeader; + } + existingHeaders.push(header); + return header; + }, + }; + + if (delimiter && delimiter !== "auto") { + config.delimiter = delimiter; + } + + try { + var result = Papa.parse(content, config); + if (result && result.meta && result.meta.fields) { + return result.meta.fields; + } + } catch (e) { + console.error("Error parsing headers:", e); + } + + return []; + } + + // ============================================ + // Matching algorithm + // ============================================ + + /** + * Calculate Jaccard similarity between two sets + * + * @param {Set} set1 + * @param {Set} set2 + * @returns {number} Similarity score 0-100 + */ + function jaccardSimilarity(set1, set2) { + if (set1.size === 0 && set2.size === 0) { + return 100; + } + if (set1.size === 0 || set2.size === 0) { + return 0; + } + + var intersection = 0; + set1.forEach(function (item) { + if (set2.has(item)) { + intersection++; + } + }); + + var union = new Set(); + set1.forEach(function (item) { + union.add(item); + }); + set2.forEach(function (item) { + union.add(item); + }); + + return Math.round((intersection / union.size) * 100); + } + + /** + * Find a partial header match among saved configs + * + * @param {string[]} headers - Headers to match + * @param {Object} configs - Saved configurations + * @returns {Object|null} Match result or null + */ + function findPartialHeaderMatch(headers, configs) { + var normalizedHeaders = new Set( + headers.map(function (h) { + return String(h || "") + .toLowerCase() + .trim(); + }), + ); + + var bestMatch = null; + var bestScore = 0; + + Object.keys(configs).forEach(function (id) { + var config = configs[id]; + + // Get original headers from chosenColumns values + var configHeaders = new Set(); + Object.values(config.chosenColumns || {}).forEach(function (v) { + if (v) { + configHeaders.add(String(v).toLowerCase().trim()); + } + }); + + // Also include headers from the original file if stored + if (config.originalHeaders && Array.isArray(config.originalHeaders)) { + config.originalHeaders.forEach(function (h) { + configHeaders.add(String(h).toLowerCase().trim()); + }); + } + + var similarity = jaccardSimilarity(normalizedHeaders, configHeaders); + + if (similarity > bestScore && similarity >= 70) { + bestScore = similarity; + bestMatch = { + configId: id, + matchType: "partial", + confidence: similarity, + config: config, + }; + } + }); + + return bestMatch; + } + + /** + * Find a filename pattern match among saved configs + * + * @param {string[]} patterns - Patterns from current filename + * @param {Object} configs - Saved configurations + * @returns {Object|null} Match result or null + */ + function findFilenameMatch(patterns, configs) { + if (!patterns || patterns.length === 0) { + return null; + } + + var bestMatch = null; + var bestMatchCount = 0; + + Object.keys(configs).forEach(function (id) { + var config = configs[id]; + var configPatterns = config.filenamePatterns || []; + + if (configPatterns.length === 0) { + return; + } + + // Count matching patterns + var matchCount = 0; + patterns.forEach(function (p) { + configPatterns.forEach(function (cp) { + if (cp.includes(p) || p.includes(cp)) { + matchCount++; + } + }); + }); + + // Require at least one matching pattern and reasonable overlap + if (matchCount > 0 && matchCount > bestMatchCount) { + bestMatchCount = matchCount; + bestMatch = { + configId: id, + matchType: "filename_only", + confidence: 60, + config: config, + }; + } + }); + + return bestMatch; + } + + /** + * Find a matching configuration for the given headers and filename + * + * @param {string[]} headers - Column headers from the file + * @param {string} filename - Name of the file + * @returns {Object|null} Match result with configId, matchType, confidence, config + */ + function findMatchingConfig(headers, filename) { + var data = getStorageData(); + var configs = data.configs; + + if (Object.keys(configs).length === 0) { + return null; + } + + // Try exact fingerprint match first + var fingerprint = generateFingerprint(headers); + if (fingerprint && configs[fingerprint]) { + return { + configId: fingerprint, + matchType: "exact", + confidence: 100, + config: configs[fingerprint], + }; + } + + // Try partial header match + var partialMatch = findPartialHeaderMatch(headers, configs); + if (partialMatch && partialMatch.confidence >= 80) { + return partialMatch; + } + + // Try filename pattern match + var filenamePatterns = extractFilenamePatterns(filename); + var filenameMatch = findFilenameMatch(filenamePatterns, configs); + + // Return best available match + if (partialMatch && partialMatch.confidence >= 70) { + return partialMatch; + } + + return filenameMatch; + } + + /** + * Find matching config trying different startAtRow values + * This handles files where headers are not on line 1 + * + * @param {string} content - Raw file content + * @param {string} filename - Filename + * @param {string} delimiter - Delimiter (or "auto") + * @returns {Object|null} Match result with startAtRow included + */ + function findMatchingConfigWithStartRow(content, filename, delimiter) { + var data = getStorageData(); + var configs = data.configs; + + if (Object.keys(configs).length === 0) { + return null; + } + + // Collect unique startAtRow values from saved configs + var startAtRows = new Set([1]); // Always try row 1 + Object.values(configs).forEach(function (config) { + if (config.startAtRow && config.startAtRow > 1) { + startAtRows.add(config.startAtRow); + } + }); + + // Try each startAtRow value + var bestMatch = null; + + startAtRows.forEach(function (startAtRow) { + var headers = extractHeadersFromContent(content, startAtRow, delimiter); + if (headers.length === 0) { + return; + } + + var match = findMatchingConfig(headers, filename); + if (match) { + // Prefer higher confidence matches + if ( + !bestMatch || + match.confidence > bestMatch.confidence || + (match.confidence === bestMatch.confidence && + match.matchType === "exact") + ) { + bestMatch = match; + bestMatch.detectedStartAtRow = startAtRow; + } + } + }); + + return bestMatch; + } + + // ============================================ + // CRUD operations + // ============================================ + + /** + * Generate a default name from filename + * + * @param {string} filename + * @returns {string} + */ + function generateDefaultName(filename) { + if (!filename) { + return "Unnamed Configuration"; + } + + var base = filename.replace(/\.(csv|xlsx?|xlsm|xlsb)$/i, ""); + base = base + .replace(/[-_]?\d{4,}/g, "") + .replace(/\(\d+\)/g, "") + .trim(); + + return base || "Unnamed Configuration"; + } + + /** + * Save a new configuration + * + * @param {string[]} headers - Column headers + * @param {string} filename - Original filename + * @param {Object} settings - Settings object with columnFormat, chosenColumns, etc. + * @param {string} [name] - Optional custom name + * @returns {string|null} Config ID (fingerprint) or null on failure + */ + function saveConfiguration(headers, filename, settings, name) { + var fingerprint = generateFingerprint(headers); + if (!fingerprint) { + return null; + } + + var data = getStorageData(); + var now = new Date().toISOString(); + var existingConfig = data.configs[fingerprint]; + + data.configs[fingerprint] = { + id: fingerprint, + name: + name || + (existingConfig ? existingConfig.name : generateDefaultName(filename)), + fingerprint: fingerprint, + filenamePatterns: extractFilenamePatterns(filename), + originalHeaders: headers.slice(), // Store original headers for partial matching + columnFormat: settings.columnFormat, + chosenColumns: settings.chosenColumns, + chosenEncoding: settings.chosenEncoding || "UTF-8", + chosenDelimiter: settings.chosenDelimiter || "auto", + startAtRow: settings.startAtRow || 1, + extraRow: settings.extraRow || false, + invertedOutflow: settings.invertedOutflow || false, + invertedAmount: settings.invertedAmount || false, + fixDates: settings.fixDates || false, + createdAt: existingConfig ? existingConfig.createdAt : now, + lastUsed: now, + useCount: (existingConfig ? existingConfig.useCount : 0) + 1, + }; + + if (saveStorageData(data)) { + return fingerprint; + } + return null; + } + + /** + * Update an existing configuration + * + * @param {string} configId - Config ID to update + * @param {Object} updates - Fields to update + * @returns {boolean} Success + */ + function updateConfiguration(configId, updates) { + var data = getStorageData(); + + if (!data.configs[configId]) { + return false; + } + + // Merge updates + Object.keys(updates).forEach(function (key) { + data.configs[configId][key] = updates[key]; + }); + + data.configs[configId].lastUsed = new Date().toISOString(); + + return saveStorageData(data); + } + + /** + * Delete a configuration + * + * @param {string} configId - Config ID to delete + * @returns {boolean} Success + */ + function deleteConfiguration(configId) { + var data = getStorageData(); + + if (!data.configs[configId]) { + return false; + } + + delete data.configs[configId]; + return saveStorageData(data); + } + + /** + * Rename a configuration + * + * @param {string} configId - Config ID to rename + * @param {string} newName - New name + * @returns {boolean} Success + */ + function renameConfiguration(configId, newName) { + return updateConfiguration(configId, { name: newName }); + } + + /** + * Get all saved configurations + * + * @returns {Object} Object with config IDs as keys + */ + function getAllConfigurations() { + return getStorageData().configs; + } + + /** + * Get a specific configuration + * + * @param {string} configId - Config ID + * @returns {Object|null} Configuration or null + */ + function getConfiguration(configId) { + return getStorageData().configs[configId] || null; + } + + /** + * Increment usage count for a configuration + * + * @param {string} configId - Config ID + * @returns {boolean} Success + */ + function incrementUsageCount(configId) { + var data = getStorageData(); + var config = data.configs[configId]; + + if (!config) { + return false; + } + + config.useCount = (config.useCount || 0) + 1; + config.lastUsed = new Date().toISOString(); + + return saveStorageData(data); + } + + // ============================================ + // Public API + // ============================================ + + return { + // Fingerprint and pattern extraction + generateFingerprint: generateFingerprint, + extractFilenamePatterns: extractFilenamePatterns, + extractHeadersFromContent: extractHeadersFromContent, + + // Matching + findMatchingConfig: findMatchingConfig, + findMatchingConfigWithStartRow: findMatchingConfigWithStartRow, + + // CRUD + saveConfiguration: saveConfiguration, + updateConfiguration: updateConfiguration, + deleteConfiguration: deleteConfiguration, + renameConfiguration: renameConfiguration, + getAllConfigurations: getAllConfigurations, + getConfiguration: getConfiguration, + incrementUsageCount: incrementUsageCount, + + // For testing + _getStorageData: getStorageData, + _simpleHash: simpleHash, + _jaccardSimilarity: jaccardSimilarity, + }; +})(); + +// Export for Node.js/testing +if (typeof module !== "undefined" && module.exports) { + module.exports = ConfigMatcher; +} diff --git a/src/data_object.js b/src/data_object.js index f5b51ac..7a41389 100644 --- a/src/data_object.js +++ b/src/data_object.js @@ -5,33 +5,144 @@ window.DataObject = class DataObject { this.base_json = null; } + // Detect if the file content is Excel format based on file extension or content + isExcelFile(filename) { + return FileUtils.isExcelFile(filename); + } + + // Parse Excel file and convert to CSV format that existing parseCsv can handle + parseExcel( + fileContent, + filename, + encoding, + startAtRow = 1, + extraRow = false, + delimiter = null, + worksheetIndex = 0, + ) { + try { + // Determine the appropriate data type for SheetJS based on file format + let dataType = "binary"; + + if (FileUtils.getExcelReadingMethod(filename) === "arrayBuffer") { + // XLS and XLSB files use OLE2 format and should be read as array buffer + dataType = "array"; + // Convert ArrayBuffer to Uint8Array if needed + if (fileContent instanceof ArrayBuffer) { + fileContent = new Uint8Array(fileContent); + } + } else { + // XLSX and XLSM files are ZIP-based and can be read as binary string + dataType = "binary"; + } + + // Read the Excel file using SheetJS with appropriate data type + const workbook = XLSX.read(fileContent, { type: dataType }); + + // Validate that we have a proper workbook + if (!workbook || typeof workbook !== "object") { + throw new Error( + "Invalid Excel file: Unable to parse workbook structure", + ); + } + + // Get worksheet names for potential multi-sheet support + const worksheetNames = workbook.SheetNames; + + if (!worksheetNames || worksheetNames.length === 0) { + throw new Error("No worksheets found in Excel file"); + } + + // Use specified worksheet index or default to first sheet + let worksheetName; + if (worksheetIndex >= 0 && worksheetIndex < worksheetNames.length) { + worksheetName = worksheetNames[worksheetIndex]; + } else if (worksheetIndex === 0 || worksheetIndex === undefined) { + worksheetName = worksheetNames[0]; + } else { + throw new Error( + `Worksheet index ${worksheetIndex} is out of range. Available sheets: ${worksheetNames.length}`, + ); + } + + const worksheet = workbook.Sheets[worksheetName]; + + if (!worksheet) { + throw new Error(`Worksheet not found: ${worksheetName}`); + } + + // Convert worksheet to CSV format + const csvContent = XLSX.utils.sheet_to_csv(worksheet); + + // Validate that we got meaningful CSV content + if (!csvContent || csvContent.trim().length === 0) { + throw new Error("No data found in selected worksheet"); + } + + // Additional validation: check if content looks like binary garbage + if (this._containsBinaryGarbage(csvContent)) { + throw new Error( + "Excel file appears to be corrupted or in an unsupported format", + ); + } + + // Store worksheet info for potential UI use + this.worksheetNames = worksheetNames; + this.currentWorksheet = worksheetName; + + // Now parse the CSV content using existing parseCsv method + return this.parseCsv( + csvContent, + encoding, + startAtRow, + extraRow, + delimiter, + ); + } catch (error) { + console.error("Error parsing Excel file:", error); + throw new Error(`Failed to parse Excel file: ${error.message}`); + } + } + + // Helper method to detect if CSV content contains binary garbage + _containsBinaryGarbage(csvContent) { + // Check for excessive non-printable characters + const nonPrintableCount = ( + csvContent.match(/[\x00-\x08\x0E-\x1F\x7F-\xFF]/g) || [] + ).length; + const totalLength = csvContent.length; + + // If more than 30% of content is non-printable, it's likely binary garbage + return totalLength > 0 && nonPrintableCount / totalLength > 0.3; + } + // Parse base csv file as JSON. This will be easier to work with. // It uses http://papaparse.com/ for handling parsing - parseCsv(csv, encoding, startAtRow=1, extraRow=false, delimiter=null) { + parseCsv(csv, encoding, startAtRow = 1, extraRow = false, delimiter = null) { let existingHeaders = []; let config = { header: true, skipEmptyLines: true, - beforeFirstChunk: function(chunk) { + beforeFirstChunk: function (chunk) { var rows = chunk.split("\n"); var startIndex = startAtRow - 1; rows = rows.slice(startIndex); if (extraRow) { - // If first row duplication is turned on, we add the first row to the top of the set again. + // If first row duplication is turned on, we add the first row to the top of the set again. rows.unshift(rows[0]); } return rows.join("\n"); }, - transformHeader: function(header) { + transformHeader: function (header) { if (header.trim().length == 0) { header = "Unnamed column"; } if (existingHeaders.indexOf(header) != -1) { let new_header = header; let counter = 0; - while(existingHeaders.indexOf(new_header) != -1){ + while (existingHeaders.indexOf(new_header) != -1) { counter++; new_header = header + " (" + counter + ")"; } @@ -39,10 +150,10 @@ window.DataObject = class DataObject { } existingHeaders.push(header); return header; - } - } + }, + }; if (delimiter !== null) { - config.delimiter = delimiter + config.delimiter = delimiter; } var result = Papa.parse(csv, config); @@ -58,6 +169,41 @@ window.DataObject = class DataObject { return this.base_json.data; } + static fixTwoDigitYear(dateStr) { + if (!dateStr) return dateStr; + if (/\d{4}/.test(dateStr)) return dateStr; + // Year at start: YY-MM-DD, YY/MM/DD — only when first part > 31 (unambiguously a year) + var startMatch = dateStr.match(/^(\d{2})([/\-.]\d{1,2}[/\-.]\d{1,2})$/); + if (startMatch && parseInt(startMatch[1], 10) > 31) { + return "20" + startMatch[1] + startMatch[2]; + } + // Year at end: MM/DD/YY, DD.MM.YY, DD-MM-YY + dateStr = dateStr.replace( + /^(\d{1,2}[/\-.]\d{1,2}[/\-.])(\d{2})$/, + "$120$2", + ); + return dateStr; + } + + hasShortYearDates(dateColumnName) { + if (!this.base_json || !this.base_json.data || !dateColumnName) { + return false; + } + var rows = this.base_json.data.slice(0, 10); + var shortCount = 0; + var total = 0; + rows.forEach(function (row) { + var val = row[dateColumnName]; + if (val && typeof val === "string" && val.trim().length > 0) { + total++; + if (!/\d{4}/.test(val)) { + shortCount++; + } + } + }); + return total > 0 && shortCount > total / 2; + } + // This method converts base_json into a json file with YNAB specific fields based on // which fields you choose in the dropdowns in the browser. @@ -67,7 +213,16 @@ window.DataObject = class DataObject { // lookup: hash definition of YNAB column names to selected base column names. Lets us // convert the uploaded CSV file into the columns that YNAB expects. // inverted_outflow: if true, positive values represent outflow while negative values represent inflow - converted_json(limit, ynab_cols, lookup, inverted_outflow = false) { + // inverted_amount: if true, flip the sign on Amount values (positive becomes negative, negative becomes positive) + // fix_dates: if true, convert 2-digit years to 4-digit years in Date column + converted_json( + limit, + ynab_cols, + lookup, + inverted_outflow = false, + inverted_amount = false, + fix_dates = false, + ) { var value; if (this.base_json === null) { return null; @@ -86,27 +241,41 @@ window.DataObject = class DataObject { // the rest are just returned as they are. if (cell) { switch (col) { + case "Date": + tmp_row[col] = fix_dates + ? DataObject.fixTwoDigitYear(cell) + : cell; + break; case "Outflow": - - if (lookup['Outflow'] == lookup['Inflow']) { + if (lookup["Outflow"] == lookup["Inflow"]) { if (!inverted_outflow) { - tmp_row[col] = cell.startsWith('-') ? cell.slice(1) : ""; + tmp_row[col] = cell.startsWith("-") ? cell.slice(1) : ""; } else { - tmp_row[col] = cell.startsWith('-') ? "" : cell; + tmp_row[col] = cell.startsWith("-") ? "" : cell; } } else { - tmp_row[col] = cell.startsWith('-') ? cell.slice(1) : cell; + tmp_row[col] = cell.startsWith("-") ? cell.slice(1) : cell; } break; case "Inflow": - if (lookup['Outflow'] == lookup['Inflow']) { + if (lookup["Outflow"] == lookup["Inflow"]) { if (!inverted_outflow) { - tmp_row[col] = cell.startsWith('-') ? "" : cell; + tmp_row[col] = cell.startsWith("-") ? "" : cell; } else { - tmp_row[col] = cell.startsWith('-') ? cell.slice(1) : ""; + tmp_row[col] = cell.startsWith("-") ? cell.slice(1) : ""; } } else { - tmp_row[col] = cell.startsWith('-') ? cell.slice(1) : cell; + tmp_row[col] = cell.startsWith("-") ? cell.slice(1) : cell; + } + break; + case "Amount": + if (inverted_amount) { + // Flip sign: remove "-" if negative, add "-" if positive + tmp_row[col] = cell.startsWith("-") + ? cell.slice(1) + : "-" + cell; + } else { + tmp_row[col] = cell; } break; default: @@ -121,14 +290,28 @@ window.DataObject = class DataObject { return value; } - converted_csv(limit, ynab_cols, lookup, inverted_outflow) { + converted_csv( + limit, + ynab_cols, + lookup, + inverted_outflow, + inverted_amount, + fix_dates = false, + ) { var string; if (this.base_json === null) { return nil; } // Papa.unparse string string = '"' + ynab_cols.join('","') + '"\n'; - this.converted_json(limit, ynab_cols, lookup, inverted_outflow).forEach(function (row) { + this.converted_json( + limit, + ynab_cols, + lookup, + inverted_outflow, + inverted_amount, + fix_dates, + ).forEach(function (row) { var row_values; row_values = []; ynab_cols.forEach(function (col) { @@ -142,4 +325,4 @@ window.DataObject = class DataObject { }); return string; } -}; \ No newline at end of file +}; diff --git a/src/file_utils.js b/src/file_utils.js new file mode 100644 index 0000000..65fe343 --- /dev/null +++ b/src/file_utils.js @@ -0,0 +1,70 @@ +// File processing utility functions +(function () { + "use strict"; + + // File extension constants + var EXCEL_EXTENSIONS = ["xlsx", "xls", "xlsm", "xlsb"]; + var BINARY_FORMAT_EXTENSIONS = ["xls", "xlsb"]; + + function getFileExtension(filename) { + if (!filename) return ""; + return filename.toLowerCase().split(".").pop(); + } + + function isExcelFile(filename) { + if (!filename) return false; + var extension = getFileExtension(filename); + return EXCEL_EXTENSIONS.includes(extension); + } + + function getExcelReadingMethod(filename) { + var extension = getFileExtension(filename); + return BINARY_FORMAT_EXTENSIONS.includes(extension) + ? "arrayBuffer" + : "binaryString"; + } + + function getFileType(filename) { + if (!filename) return ""; + var extension = getFileExtension(filename); + if (EXCEL_EXTENSIONS.includes(extension)) { + return extension.toUpperCase(); + } + return "CSV"; + } + + function getFileTypeConstants() { + return { + EXCEL_EXTENSIONS: EXCEL_EXTENSIONS, + BINARY_FORMAT_EXTENSIONS: BINARY_FORMAT_EXTENSIONS, + }; + } + + function createDataWrapper(content, filename) { + return { + data: content, + filename: filename, + }; + } + + // Public API + var FileUtils = { + getFileExtension: getFileExtension, + isExcelFile: isExcelFile, + getExcelReadingMethod: getExcelReadingMethod, + getFileType: getFileType, + constants: getFileTypeConstants, + createDataWrapper: createDataWrapper, + }; + + // Support both browser and Node.js environments + if (typeof window !== "undefined") { + window.FileUtils = FileUtils; + } + if (typeof module !== "undefined" && module.exports) { + module.exports = FileUtils; + } + if (typeof global !== "undefined") { + global.FileUtils = FileUtils; + } +})(); diff --git a/test_files/chase_statement_2024.csv b/test_files/chase_statement_2024.csv new file mode 100644 index 0000000..ebd8766 --- /dev/null +++ b/test_files/chase_statement_2024.csv @@ -0,0 +1,5 @@ +Date,Description,Amount,Balance +12/15/2024,AMAZON.COM*1234567,-45.99,1234.56 +12/14/2024,STARBUCKS STORE 12345,-5.75,1280.55 +12/13/2024,PAYROLL DEPOSIT,2500.00,1286.30 +12/12/2024,NETFLIX.COM,-15.99,-1213.70 diff --git a/test_files/chase_statement_2025.csv b/test_files/chase_statement_2025.csv new file mode 100644 index 0000000..c8b9f9e --- /dev/null +++ b/test_files/chase_statement_2025.csv @@ -0,0 +1,5 @@ +Date,Description,Amount,Balance +01/05/2025,WHOLE FOODS MARKET,-67.32,2345.67 +01/04/2025,UBER *TRIP,-12.50,2412.99 +01/03/2025,DIRECT DEPOSIT,3000.00,2425.49 +01/02/2025,SPOTIFY USA,-9.99,-574.51 diff --git a/test_files/kontoutdrag 20251227-0654.csv b/test_files/kontoutdrag 20251227-0654.csv new file mode 100644 index 0000000..979d8ef --- /dev/null +++ b/test_files/kontoutdrag 20251227-0654.csv @@ -0,0 +1,2 @@ +Booking date;Value date;Voucher number;Text;Amount;Balance +2025-12-23;2025-12-23;1234567890;Something;-59990.700;12345.810 diff --git a/test_files/kontoutdrag 20260103-0639.csv b/test_files/kontoutdrag 20260103-0639.csv new file mode 100644 index 0000000..536dfdd --- /dev/null +++ b/test_files/kontoutdrag 20260103-0639.csv @@ -0,0 +1,2 @@ +Bokföringsdatum;Valutadatum;Verifikationsnummer;Text;Belopp;Saldo +2026-01-02;2025-12-31;1234567890;AUT13:54;-1000.000;2292.190 \ No newline at end of file diff --git a/test_files/metadata_header_row3.csv b/test_files/metadata_header_row3.csv new file mode 100644 index 0000000..b73cb13 --- /dev/null +++ b/test_files/metadata_header_row3.csv @@ -0,0 +1,6 @@ +Account Statement +Generated: 2025-01-10 +Date,Payee,Amount +2025-01-05,Coffee Shop,-4.50 +2025-01-04,Grocery Store,-52.30 +2025-01-03,Gas Station,-45.00 diff --git a/test_files/metadata_header_row3_v2.csv b/test_files/metadata_header_row3_v2.csv new file mode 100644 index 0000000..58ecfb0 --- /dev/null +++ b/test_files/metadata_header_row3_v2.csv @@ -0,0 +1,6 @@ +Monthly Report +Created: 2025-01-15 +Date,Payee,Amount +2025-01-12,Restaurant,-35.00 +2025-01-11,Pharmacy,-12.99 +2025-01-10,Bookstore,-24.50 diff --git a/test_files/test.xls b/test_files/test.xls new file mode 100644 index 0000000..10ffef9 Binary files /dev/null and b/test_files/test.xls differ diff --git a/test_files/test.xlsb b/test_files/test.xlsb new file mode 100644 index 0000000..427d23e Binary files /dev/null and b/test_files/test.xlsb differ diff --git a/test_files/test.xlsm b/test_files/test.xlsm new file mode 100644 index 0000000..0cdeea1 Binary files /dev/null and b/test_files/test.xlsm differ diff --git a/test_files/test.xlsx b/test_files/test.xlsx new file mode 100644 index 0000000..0ec90e9 Binary files /dev/null and b/test_files/test.xlsx differ diff --git a/test_files/wells_fargo_checking.csv b/test_files/wells_fargo_checking.csv new file mode 100644 index 0000000..ade1396 --- /dev/null +++ b/test_files/wells_fargo_checking.csv @@ -0,0 +1,5 @@ +Post Date,Description,Withdrawals,Deposits,Balance +01/10/2025,CHECK #1234,150.00,,3456.78 +01/09/2025,ATM WITHDRAWAL,200.00,,3606.78 +01/08/2025,DIRECT DEP PAYROLL,,2800.00,3806.78 +01/07/2025,UTILITY PAYMENT,125.50,,1006.78 diff --git a/tests/app.test.js b/tests/app.test.js index 80de031..f95d365 100644 --- a/tests/app.test.js +++ b/tests/app.test.js @@ -2,42 +2,64 @@ const mockModule = { directive: jest.fn(() => mockModule), config: jest.fn(() => mockModule), - controller: jest.fn(() => mockModule) + controller: jest.fn(() => mockModule), }; global.angular = { element: jest.fn(() => ({ - ready: jest.fn((callback) => callback()) + ready: jest.fn((callback) => callback()), })), module: jest.fn(() => mockModule), - bootstrap: jest.fn() + bootstrap: jest.fn(), + copy: jest.fn((obj) => JSON.parse(JSON.stringify(obj))), }; // Mock DataObject global.DataObject = jest.fn(() => ({ parseCsv: jest.fn(), + parseExcel: jest.fn(), + isExcelFile: jest.fn(), converted_json: jest.fn(), converted_csv: jest.fn(), fields: jest.fn(() => []), - rows: jest.fn(() => []) + rows: jest.fn(() => []), + hasShortYearDates: jest.fn(() => false), + worksheetNames: [], + currentWorksheet: null, })); // Mock document global.document = { createElement: jest.fn(() => ({ - click: jest.fn() + click: jest.fn(), })), body: { - appendChild: jest.fn() - } + appendChild: jest.fn(), + }, }; +// Mock ConfigMatcher +global.ConfigMatcher = { + getAllConfigurations: jest.fn(() => ({})), + getConfiguration: jest.fn(), + findMatchingConfig: jest.fn(), + findMatchingConfigWithStartRow: jest.fn(), + saveConfiguration: jest.fn(), + updateConfiguration: jest.fn(), + deleteConfiguration: jest.fn(), + renameConfiguration: jest.fn(), + incrementUsageCount: jest.fn(), +}; + +// Mock alert +global.alert = jest.fn(); + // Mock Date prototype -global.Date.prototype.yyyymmdd = function() { - return '20240101'; +global.Date.prototype.yyyymmdd = function () { + return "20240101"; }; -describe('ParseController', () => { +describe("ParseController", () => { let $scope; let $location; let controller; @@ -46,303 +68,2058 @@ describe('ParseController', () => { // Clear all mocks jest.clearAllMocks(); jest.resetModules(); - + // Create mock $scope $scope = { $watch: jest.fn(), - $apply: jest.fn((fn) => fn && fn()) + $apply: jest.fn((fn) => fn && fn()), + $evalAsync: jest.fn(), }; - - // Create mock $location + + // Create mock $location $location = { - search: jest.fn(() => ({})) + search: jest.fn(() => ({})), }; - + // Load the app.js file to register the controller - require('../src/app.js'); - + require("../src/app.js"); + // Get the controller function that was registered const controllerCalls = mockModule.controller.mock.calls; - const parseControllerCall = controllerCalls.find(call => call[0] === 'ParseController'); + const parseControllerCall = controllerCalls.find( + (call) => call[0] === "ParseController", + ); const controllerFn = parseControllerCall[1]; - + // Execute the controller controller = controllerFn($scope, $location); }); - describe('Profile Management', () => { - test('should initialize with default profile settings', () => { - expect($scope.profileName).toBe('default profile'); - expect($scope.profiles).toHaveProperty('default profile'); + describe("Profile Management", () => { + test("should initialize with default profile settings", () => { + expect($scope.profileName).toBe("default profile"); + expect($scope.profiles).toHaveProperty("default profile"); expect($scope.profile).toBeDefined(); - expect($scope.ynab_cols).toEqual(['Date', 'Payee', 'Memo', 'Outflow', 'Inflow']); - expect($scope.file.chosenEncoding).toBe('UTF-8'); - expect($scope.file.chosenDelimiter).toBe('auto'); + expect($scope.ynab_cols).toEqual([ + "Date", + "Payee", + "Memo", + "Outflow", + "Inflow", + ]); + expect($scope.file.chosenEncoding).toBe("UTF-8"); + expect($scope.file.chosenDelimiter).toBe("auto"); }); - test('should detect non-default profiles exist', () => { + test("should detect non-default profiles exist", () => { // Initially only has default profile - $scope.profiles = { 'default profile': {} }; + $scope.profiles = { "default profile": {} }; expect($scope.nonDefaultProfilesExist()).toBe(false); - + // Add another profile - $scope.profiles['custom-profile'] = {}; + $scope.profiles["custom-profile"] = {}; expect($scope.nonDefaultProfilesExist()).toBe(true); }); - test('should toggle between old and new column formats', () => { + test("should toggle between old and new column formats", () => { // Start with old format - expect($scope.ynab_cols).toEqual(['Date', 'Payee', 'Memo', 'Outflow', 'Inflow']); - + expect($scope.ynab_cols).toEqual([ + "Date", + "Payee", + "Memo", + "Outflow", + "Inflow", + ]); + // Toggle to new format $scope.toggleColumnFormat(); - expect($scope.ynab_cols).toEqual(['Date', 'Payee', 'Memo', 'Amount']); - expect($scope.profile.columnFormat).toEqual(['Date', 'Payee', 'Memo', 'Amount']); - + expect($scope.ynab_cols).toEqual(["Date", "Payee", "Memo", "Amount"]); + expect($scope.profile.columnFormat).toEqual([ + "Date", + "Payee", + "Memo", + "Amount", + ]); + // Toggle back to old format $scope.toggleColumnFormat(); - expect($scope.ynab_cols).toEqual(['Date', 'Payee', 'Memo', 'Outflow', 'Inflow']); + expect($scope.ynab_cols).toEqual([ + "Date", + "Payee", + "Memo", + "Outflow", + "Inflow", + ]); }); - test('should update profile encoding when chosen', () => { - $scope.encodingChosen('windows-1252'); - expect($scope.profile.chosenEncoding).toBe('windows-1252'); + test("should update profile encoding when chosen", () => { + $scope.encodingChosen("windows-1252"); + expect($scope.profile.chosenEncoding).toBe("windows-1252"); }); - test('should update profile delimiter when chosen', () => { - $scope.delimiterChosen(';'); - expect($scope.profile.chosenDelimiter).toBe(';'); + test("should update profile delimiter when chosen", () => { + $scope.delimiterChosen(";"); + expect($scope.profile.chosenDelimiter).toBe(";"); }); - test('should update start row when set', () => { + test("should update start row when set", () => { $scope.startRowSet(3); expect($scope.profile.startAtRow).toBe(3); }); - test('should update extra row setting when set', () => { + test("should update extra row setting when set", () => { $scope.extraRowSet(true); expect($scope.profile.extraRow).toBe(true); }); - test('should switch profiles and call URL search', () => { - $scope.profileChosen('new-profile'); - - expect($location.search).toHaveBeenCalledWith('profile', 'new-profile'); + test("should switch profiles and call URL search", () => { + $scope.profileChosen("new-profile"); + + expect($location.search).toHaveBeenCalledWith("profile", "new-profile"); // Note: The function uses $scope.profileName instead of the parameter, // so it will use the existing profile, not the new one expect($scope.profile).toBe($scope.profiles[$scope.profileName]); }); - test('should invert flows when toggle is called', () => { + test("should invert flows when toggle is called", () => { expect($scope.inverted_outflow).toBe(false); - + $scope.invert_flows(); expect($scope.inverted_outflow).toBe(true); - + $scope.invert_flows(); expect($scope.inverted_outflow).toBe(false); }); - test('should reset app state when reloadApp is called', () => { + test("should invert amount when toggle is called", () => { + expect($scope.inverted_amount).toBe(false); + + $scope.invert_amount(); + expect($scope.inverted_amount).toBe(true); + + $scope.invert_amount(); + expect($scope.inverted_amount).toBe(false); + }); + + test("should initialize fix_dates to false", () => { + expect($scope.fix_dates).toBe(false); + }); + + test("should toggle fix_dates when toggle is called", () => { + expect($scope.fix_dates).toBe(false); + + $scope.toggle_fix_dates(); + expect($scope.fix_dates).toBe(true); + + $scope.toggle_fix_dates(); + expect($scope.fix_dates).toBe(false); + }); + + test("should reset app state when reloadApp is called", () => { $scope.setInitialScopeState = jest.fn(); $scope.reloadApp(); - + expect($scope.setInitialScopeState).toHaveBeenCalled(); }); }); - describe('File Processing', () => { + describe("File Processing", () => { beforeEach(() => { // Set up data object mock methods $scope.data_object.parseCsv = jest.fn(); $scope.data_object.converted_json = jest.fn(() => [ - { Date: '2024-01-01', Payee: 'Store', Amount: '-50.00' } + { Date: "2024-01-01", Payee: "Store", Amount: "-50.00" }, ]); - $scope.data_object.converted_csv = jest.fn(() => 'Date,Payee,Amount\n2024-01-01,Store,-50.00'); + $scope.data_object.converted_csv = jest.fn( + () => "Date,Payee,Amount\n2024-01-01,Store,-50.00", + ); }); - test('should process file data when data.source changes', () => { + test("should process file data when data.source changes", () => { // Set up watchers const watchCallbacks = {}; $scope.$watch.mockImplementation((expr, callback) => { watchCallbacks[expr] = callback; }); - + // Re-initialize to capture watch callbacks jest.resetModules(); - require('../src/app.js'); + require("../src/app.js"); const controllerCalls = mockModule.controller.mock.calls; - const parseControllerCall = controllerCalls.find(call => call[0] === 'ParseController'); + const parseControllerCall = controllerCalls.find( + (call) => call[0] === "ParseController", + ); const controllerFn = parseControllerCall[1]; controllerFn($scope, $location); - + // Simulate file data change with auto delimiter - $scope.file.chosenDelimiter = 'auto'; + $scope.file.chosenDelimiter = "auto"; $scope.file.startAtRow = 1; $scope.profile.extraRow = false; - const csvData = 'Date,Payee,Amount\n2024-01-01,Store,-50.00'; - - watchCallbacks['data.source'](csvData, null); - + const csvData = { + data: "Date,Payee,Amount\n2024-01-01,Store,-50.00", + filename: "test.csv", + }; + + watchCallbacks["data.source"](csvData, null); + expect($scope.data_object.parseCsv).toHaveBeenCalledWith( - csvData, + csvData.data, $scope.file.chosenEncoding, $scope.file.startAtRow, - $scope.profile.extraRow + $scope.profile.extraRow, ); expect($scope.data_object.converted_json).toHaveBeenCalledWith( 10, $scope.ynab_cols, $scope.ynab_map, - $scope.inverted_outflow + $scope.inverted_outflow, + $scope.inverted_amount, + $scope.fix_dates, ); }); - test('should process file data with custom delimiter', () => { + test("should process file data with custom delimiter", () => { // Set up watchers const watchCallbacks = {}; $scope.$watch.mockImplementation((expr, callback) => { watchCallbacks[expr] = callback; }); - + // Re-initialize to capture watch callbacks jest.resetModules(); - require('../src/app.js'); + require("../src/app.js"); const controllerCalls = mockModule.controller.mock.calls; - const parseControllerCall = controllerCalls.find(call => call[0] === 'ParseController'); + const parseControllerCall = controllerCalls.find( + (call) => call[0] === "ParseController", + ); const controllerFn = parseControllerCall[1]; controllerFn($scope, $location); - + // Simulate file data change with custom delimiter - $scope.file.chosenDelimiter = ';'; + $scope.file.chosenDelimiter = ";"; $scope.file.startAtRow = 2; $scope.profile.extraRow = true; - const csvData = 'Date;Payee;Amount\n2024-01-01;Store;-50.00'; - - watchCallbacks['data.source'](csvData, null); - + const csvData = { + data: "Date;Payee;Amount\n2024-01-01;Store;-50.00", + filename: "test.csv", + }; + + watchCallbacks["data.source"](csvData, null); + expect($scope.data_object.parseCsv).toHaveBeenCalledWith( - csvData, + csvData.data, $scope.file.chosenEncoding, $scope.file.startAtRow, $scope.profile.extraRow, - ';' + ";", ); }); - test('should update preview when inverted_outflow changes', () => { + test("should update preview when inverted_outflow changes", () => { // Set up watchers const watchCallbacks = {}; $scope.$watch.mockImplementation((expr, callback) => { watchCallbacks[expr] = callback; }); - + // Re-initialize to capture watch callbacks jest.resetModules(); - require('../src/app.js'); + require("../src/app.js"); const controllerCalls = mockModule.controller.mock.calls; - const parseControllerCall = controllerCalls.find(call => call[0] === 'ParseController'); + const parseControllerCall = controllerCalls.find( + (call) => call[0] === "ParseController", + ); const controllerFn = parseControllerCall[1]; controllerFn($scope, $location); - + // Set initial inverted_outflow to false $scope.inverted_outflow = false; - + // Simulate inverted outflow change - the watch callback should update preview $scope.inverted_outflow = true; - watchCallbacks['inverted_outflow'](true, false); - + watchCallbacks["inverted_outflow"](true, false); + + expect($scope.data_object.converted_json).toHaveBeenCalledWith( + 10, + $scope.ynab_cols, + $scope.ynab_map, + $scope.inverted_outflow, + $scope.inverted_amount, + $scope.fix_dates, + ); + }); + + test("should update preview when inverted_amount changes", () => { + // Set up watchers + const watchCallbacks = {}; + $scope.$watch.mockImplementation((expr, callback) => { + watchCallbacks[expr] = callback; + }); + + // Re-initialize to capture watch callbacks + jest.resetModules(); + require("../src/app.js"); + const controllerCalls = mockModule.controller.mock.calls; + const parseControllerCall = controllerCalls.find( + (call) => call[0] === "ParseController", + ); + const controllerFn = parseControllerCall[1]; + controllerFn($scope, $location); + + // Set initial inverted_amount to false + $scope.inverted_amount = false; + + // Simulate inverted amount change - the watch callback should update preview + $scope.inverted_amount = true; + watchCallbacks["inverted_amount"](true, false); + + expect($scope.data_object.converted_json).toHaveBeenCalledWith( + 10, + $scope.ynab_cols, + $scope.ynab_map, + $scope.inverted_outflow, + $scope.inverted_amount, + $scope.fix_dates, + ); + }); + + test("should update preview when fix_dates changes", () => { + // Set up watchers + const watchCallbacks = {}; + $scope.$watch.mockImplementation((expr, callback) => { + watchCallbacks[expr] = callback; + }); + + // Re-initialize to capture watch callbacks + jest.resetModules(); + require("../src/app.js"); + const controllerCalls = mockModule.controller.mock.calls; + const parseControllerCall = controllerCalls.find( + (call) => call[0] === "ParseController", + ); + const controllerFn = parseControllerCall[1]; + controllerFn($scope, $location); + + // Simulate fix_dates change + $scope.fix_dates = true; + watchCallbacks["fix_dates"](true, false); + expect($scope.data_object.converted_json).toHaveBeenCalledWith( 10, $scope.ynab_cols, $scope.ynab_map, - $scope.inverted_outflow + $scope.inverted_outflow, + $scope.inverted_amount, + $scope.fix_dates, ); }); - test('should update preview when column mapping changes', () => { + test("should update preview when column mapping changes", () => { // Set up watchers const watchCallbacks = {}; $scope.$watch.mockImplementation((expr, callback, deep) => { watchCallbacks[expr] = callback; }); - + // Re-initialize to capture watch callbacks jest.resetModules(); - require('../src/app.js'); + require("../src/app.js"); const controllerCalls = mockModule.controller.mock.calls; - const parseControllerCall = controllerCalls.find(call => call[0] === 'ParseController'); + const parseControllerCall = controllerCalls.find( + (call) => call[0] === "ParseController", + ); const controllerFn = parseControllerCall[1]; controllerFn($scope, $location); - + // Simulate column mapping change - const newMapping = { Date: 'Transaction Date', Payee: 'Merchant' }; - watchCallbacks['ynab_map'](newMapping, {}); - + const newMapping = { Date: "Transaction Date", Payee: "Merchant" }; + watchCallbacks["ynab_map"](newMapping, {}); + expect($scope.profile.chosenColumns).toEqual(newMapping); expect($scope.data_object.converted_json).toHaveBeenCalledWith( 10, $scope.ynab_cols, newMapping, - $scope.inverted_outflow + $scope.inverted_outflow, + $scope.inverted_amount, + $scope.fix_dates, ); }); - test('should generate CSV string for download', () => { - $scope.data_object.converted_csv.mockReturnValue('Date,Payee,Amount\n2024-01-01,Store,-50.00'); - + test("should generate CSV string for download", () => { + $scope.data_object.converted_csv.mockReturnValue( + "Date,Payee,Amount\n2024-01-01,Store,-50.00", + ); + const result = $scope.csvString(); - + expect($scope.data_object.converted_csv).toHaveBeenCalledWith( null, $scope.ynab_cols, $scope.ynab_map, - $scope.inverted_outflow + $scope.inverted_outflow, + $scope.inverted_amount, + $scope.fix_dates, ); - expect(result).toBe('Date,Payee,Amount\n2024-01-01,Store,-50.00'); + expect(result).toBe("Date,Payee,Amount\n2024-01-01,Store,-50.00"); }); - test('should create download file with proper filename and encoding', () => { + test("should create download file with proper filename and encoding", () => { const mockAnchor = { - href: '', - target: '', - download: '', - click: jest.fn() + href: "", + target: "", + download: "", + click: jest.fn(), }; - + // Mock Date constructor to return a specific date - const mockDate = new Date('2024-01-01'); - mockDate.yyyymmdd = jest.fn(() => '20240101'); - jest.spyOn(global, 'Date').mockImplementation(() => mockDate); - + const mockDate = new Date("2024-01-01"); + mockDate.yyyymmdd = jest.fn(() => "20240101"); + jest.spyOn(global, "Date").mockImplementation(() => mockDate); + // Mock document.createElement to return our mock anchor global.document.createElement = jest.fn(() => mockAnchor); global.document.body.appendChild = jest.fn(); - + // Use realistic CSV data with special characters to test encoding - const csvData = 'Date,Payee,Memo,Amount\n2024-01-01,"Store ""ABC""","Café & Groceries","-$50.00"'; + const csvData = + 'Date,Payee,Memo,Amount\n2024-01-01,"Store ""ABC""","Café & Groceries","-$50.00"'; $scope.data_object.converted_csv.mockReturnValue(csvData); - + $scope.downloadFile(); - + // Verify DOM interactions - expect(global.document.createElement).toHaveBeenCalledWith('a'); - expect(mockAnchor.target).toBe('_blank'); - expect(mockAnchor.download).toBe('ynab_data_20240101.csv'); + expect(global.document.createElement).toHaveBeenCalledWith("a"); + expect(mockAnchor.target).toBe("_blank"); + expect(mockAnchor.download).toBe("ynab_data_20240101.csv"); expect(global.document.body.appendChild).toHaveBeenCalledWith(mockAnchor); expect(mockAnchor.click).toHaveBeenCalled(); - + // Verify the actual encoding pipeline: btoa(unescape(encodeURIComponent(csvData))) - const expectedHref = 'data:attachment/csv;base64,' + btoa(unescape(encodeURIComponent(csvData))); + const expectedHref = + "data:attachment/csv;base64," + + btoa(unescape(encodeURIComponent(csvData))); expect(mockAnchor.href).toBe(expectedHref); - + // Verify we can decode it back to the original CSV data - const base64Part = mockAnchor.href.split('data:attachment/csv;base64,')[1]; + const base64Part = mockAnchor.href.split( + "data:attachment/csv;base64,", + )[1]; const decodedData = decodeURIComponent(escape(atob(base64Part))); expect(decodedData).toBe(csvData); - + // Restore Date mock global.Date.mockRestore(); }); }); -}); \ No newline at end of file + + describe("Worksheet Selection", () => { + beforeEach(() => { + // Set up Excel file scenario + $scope.currentFilename = "test.xlsx"; + $scope.data = { + source: { + data: "excel_binary_data", + filename: "test.xlsx", + }, + }; + $scope.data_object.parseExcel = jest.fn(); + $scope.data_object.converted_json = jest.fn(() => [ + { Date: "2024-01-01", Payee: "Excel Store", Amount: "-75.00" }, + ]); + $scope.$evalAsync = jest.fn(); + global.alert = jest.fn(); + }); + + test("worksheetChosen should re-parse Excel with new worksheet index", () => { + $scope.worksheetChosen(2); + + expect($scope.data_object.parseExcel).toHaveBeenCalledWith( + "excel_binary_data", + "test.xlsx", + $scope.file.chosenEncoding, + $scope.file.startAtRow, + $scope.profile.extraRow, + null, // auto delimiter becomes null + 2, // worksheet index + ); + + expect($scope.data_object.converted_json).toHaveBeenCalledWith( + 10, + $scope.ynab_cols, + $scope.ynab_map, + $scope.inverted_outflow, + $scope.inverted_amount, + $scope.fix_dates, + ); + }); + + test("worksheetChosen should handle custom delimiter", () => { + $scope.file.chosenDelimiter = ";"; + + $scope.worksheetChosen(1); + + expect($scope.data_object.parseExcel).toHaveBeenCalledWith( + "excel_binary_data", + "test.xlsx", + $scope.file.chosenEncoding, + $scope.file.startAtRow, + $scope.profile.extraRow, + ";", // custom delimiter + 1, + ); + }); + + test("worksheetChosen should handle parseExcel errors gracefully", () => { + // Mock console.error and alert to avoid noise in tests + global.console.error = jest.fn(); + global.alert = jest.fn(); + + // Make parseExcel throw an error + $scope.data_object.parseExcel.mockImplementation(() => { + throw new Error("Invalid worksheet"); + }); + + // This should not throw + expect(() => { + $scope.worksheetChosen(5); + }).not.toThrow(); + + expect(global.console.error).toHaveBeenCalledWith( + "Error switching worksheet:", + expect.any(Error), + ); + expect(global.alert).toHaveBeenCalledWith( + "Error switching worksheet: Invalid worksheet", + ); + + // Clean up mocks + delete global.console.error; + delete global.alert; + }); + + test("worksheetChosen should do nothing if filename is not set", () => { + $scope.currentFilename = null; + + $scope.worksheetChosen(1); + + expect($scope.data_object.parseExcel).not.toHaveBeenCalled(); + expect($scope.data_object.converted_json).not.toHaveBeenCalled(); + }); + + test("worksheetChosen should do nothing if data.source is not set", () => { + $scope.data.source = null; + + $scope.worksheetChosen(1); + + expect($scope.data_object.parseExcel).not.toHaveBeenCalled(); + expect($scope.data_object.converted_json).not.toHaveBeenCalled(); + }); + + test("worksheetChosen should do nothing for non-Excel files", () => { + $scope.currentFilename = "test.csv"; + + $scope.worksheetChosen(1); + + expect($scope.data_object.parseExcel).not.toHaveBeenCalled(); + expect($scope.data_object.converted_json).not.toHaveBeenCalled(); + }); + }); + + describe("Excel File Processing Integration", () => { + beforeEach(() => { + // Set up data object mock methods for Excel + $scope.data_object.parseExcel = jest.fn(); + $scope.data_object.isExcelFile = jest.fn(); + $scope.data_object.converted_json = jest.fn(() => [ + { Date: "2024-01-01", Payee: "Excel Store", Amount: "-75.00" }, + ]); + $scope.data_object.worksheetNames = ["Sheet1", "Data"]; + }); + + test("data.source watcher should call parseExcel for Excel files", () => { + // Set up watchers + const watchCallbacks = {}; + $scope.$watch.mockImplementation((expr, callback) => { + watchCallbacks[expr] = callback; + }); + + // Re-initialize to capture watch callbacks + jest.resetModules(); + require("../src/app.js"); + const controllerCalls = mockModule.controller.mock.calls; + const parseControllerCall = controllerCalls.find( + (call) => call[0] === "ParseController", + ); + const controllerFn = parseControllerCall[1]; + controllerFn($scope, $location); + + // Set up Excel file scenario + $scope.data_object.isExcelFile.mockReturnValue(true); + $scope.file.chosenDelimiter = "auto"; + $scope.file.startAtRow = 2; + $scope.profile.extraRow = true; + + const excelData = { + data: "excel_binary_data", + filename: "test.xlsx", + }; + + // Simulate Excel file data change + watchCallbacks["data.source"](excelData, null); + + expect($scope.data_object.parseExcel).toHaveBeenCalledWith( + excelData.data, + "test.xlsx", + $scope.file.chosenEncoding, + $scope.file.startAtRow, + $scope.profile.extraRow, + null, // auto delimiter becomes null + 0, // default worksheet index + ); + + expect($scope.data_object.converted_json).toHaveBeenCalledWith( + 10, + $scope.ynab_cols, + $scope.ynab_map, + $scope.inverted_outflow, + $scope.inverted_amount, + $scope.fix_dates, + ); + + // Verify worksheet initialization + expect($scope.file.selectedWorksheet).toBe(0); + }); + + test("data.source watcher should call parseExcel with custom delimiter", () => { + // Set up watchers + const watchCallbacks = {}; + $scope.$watch.mockImplementation((expr, callback) => { + watchCallbacks[expr] = callback; + }); + + // Re-initialize to capture watch callbacks + jest.resetModules(); + require("../src/app.js"); + const controllerCalls = mockModule.controller.mock.calls; + const parseControllerCall = controllerCalls.find( + (call) => call[0] === "ParseController", + ); + const controllerFn = parseControllerCall[1]; + controllerFn($scope, $location); + + // Set up Excel file scenario with custom delimiter in profile + $scope.data_object.isExcelFile.mockReturnValue(true); + $scope.profile.chosenDelimiter = ";"; + + const excelData = { + data: "excel_binary_data", + filename: "test.xlsx", + }; + + watchCallbacks["data.source"](excelData, null); + + expect($scope.data_object.parseExcel).toHaveBeenCalledWith( + excelData.data, + "test.xlsx", + $scope.file.chosenEncoding, + $scope.file.startAtRow, + $scope.profile.extraRow, + ";", // custom delimiter + 0, + ); + }); + + test("data.source watcher should call parseCsv for CSV files", () => { + // Set up watchers + const watchCallbacks = {}; + $scope.$watch.mockImplementation((expr, callback) => { + watchCallbacks[expr] = callback; + }); + + // Re-initialize to capture watch callbacks + jest.resetModules(); + require("../src/app.js"); + const controllerCalls = mockModule.controller.mock.calls; + const parseControllerCall = controllerCalls.find( + (call) => call[0] === "ParseController", + ); + const controllerFn = parseControllerCall[1]; + controllerFn($scope, $location); + + // Set up CSV file scenario + $scope.data_object.isExcelFile.mockReturnValue(false); + $scope.file.chosenDelimiter = "auto"; + + const csvData = { + data: "Date,Payee,Amount\n2024-01-01,Store,-50.00", + filename: "test.csv", + }; + + watchCallbacks["data.source"](csvData, null); + + expect($scope.data_object.parseCsv).toHaveBeenCalledWith( + csvData.data, + $scope.file.chosenEncoding, + $scope.file.startAtRow, + $scope.profile.extraRow, + ); + + expect($scope.data_object.parseExcel).not.toHaveBeenCalled(); + }); + + test("data.source watcher should handle Excel parsing errors", () => { + // Mock console.error and alert to avoid noise in tests + global.console.error = jest.fn(); + global.alert = jest.fn(); + + // Set up watchers + const watchCallbacks = {}; + $scope.$watch.mockImplementation((expr, callback) => { + watchCallbacks[expr] = callback; + }); + + // Re-initialize to capture watch callbacks + jest.resetModules(); + require("../src/app.js"); + const controllerCalls = mockModule.controller.mock.calls; + const parseControllerCall = controllerCalls.find( + (call) => call[0] === "ParseController", + ); + const controllerFn = parseControllerCall[1]; + controllerFn($scope, $location); + + // Set up Excel file scenario + $scope.data_object.isExcelFile.mockReturnValue(true); + + // Make parseExcel throw an error + $scope.data_object.parseExcel.mockImplementation(() => { + throw new Error("Corrupted Excel file"); + }); + + const excelData = { + data: "corrupted_excel_data", + filename: "test.xlsx", + }; + + // This should not crash the watcher + expect(() => { + watchCallbacks["data.source"](excelData, null); + }).not.toThrow(); + + expect(global.console.error).toHaveBeenCalledWith( + "Error parsing file:", + expect.any(Error), + ); + expect(global.alert).toHaveBeenCalledWith( + "Error parsing file: Corrupted Excel file", + ); + + // Clean up mocks + delete global.console.error; + delete global.alert; + }); + + test("data.source watcher should not process empty data", () => { + // Set up watchers + const watchCallbacks = {}; + $scope.$watch.mockImplementation((expr, callback) => { + watchCallbacks[expr] = callback; + }); + + // Re-initialize to capture watch callbacks + jest.resetModules(); + require("../src/app.js"); + const controllerCalls = mockModule.controller.mock.calls; + const parseControllerCall = controllerCalls.find( + (call) => call[0] === "ParseController", + ); + const controllerFn = parseControllerCall[1]; + controllerFn($scope, $location); + + // Test empty data scenarios + watchCallbacks["data.source"]("", null); + watchCallbacks["data.source"](null, null); + watchCallbacks["data.source"](undefined, null); + + expect($scope.data_object.parseExcel).not.toHaveBeenCalled(); + expect($scope.data_object.parseCsv).not.toHaveBeenCalled(); + }); + + test("worksheet initialization should work for Excel files", () => { + // Set up watchers + const watchCallbacks = {}; + $scope.$watch.mockImplementation((expr, callback) => { + watchCallbacks[expr] = callback; + }); + + // Re-initialize to capture watch callbacks + jest.resetModules(); + require("../src/app.js"); + const controllerCalls = mockModule.controller.mock.calls; + const parseControllerCall = controllerCalls.find( + (call) => call[0] === "ParseController", + ); + const controllerFn = parseControllerCall[1]; + controllerFn($scope, $location); + + // Set up Excel file scenario + $scope.filename = "test.xlsx"; + $scope.data_object.isExcelFile.mockReturnValue(true); + $scope.data_object.worksheetNames = ["Sheet1", "Data", "Summary"]; + + const excelData = "excel_binary_data"; + + watchCallbacks["data.source"](excelData, null); + + // Should initialize selectedWorksheet to 0 for multi-sheet Excel files + expect($scope.file.selectedWorksheet).toBe(0); + }); + + test("worksheet initialization should not occur for CSV files", () => { + // Set up watchers + const watchCallbacks = {}; + $scope.$watch.mockImplementation((expr, callback) => { + watchCallbacks[expr] = callback; + }); + + // Re-initialize to capture watch callbacks + jest.resetModules(); + require("../src/app.js"); + const controllerCalls = mockModule.controller.mock.calls; + const parseControllerCall = controllerCalls.find( + (call) => call[0] === "ParseController", + ); + const controllerFn = parseControllerCall[1]; + controllerFn($scope, $location); + + // Set up CSV file scenario + $scope.data_object.isExcelFile.mockReturnValue(false); + + const csvData = { + data: "Date,Payee,Amount\n2024-01-01,Store,-50.00", + filename: "test.csv", + }; + + watchCallbacks["data.source"](csvData, null); + + // Should not set selectedWorksheet for CSV files (it starts as 0 from setInitialScopeState) + expect($scope.file.selectedWorksheet).toBe(0); + }); + }); + + describe("File Re-parsing", () => { + beforeEach(() => { + // Mock FileUtils globally + global.FileUtils = { + isExcelFile: jest.fn(), + }; + + // Set up initial controller state + jest.resetModules(); + require("../src/app.js"); + const controllerCalls = mockModule.controller.mock.calls; + const parseControllerCall = controllerCalls.find( + (call) => call[0] === "ParseController", + ); + const controllerFn = parseControllerCall[1]; + controllerFn($scope, $location); + + // Mock the setting functions + $scope.encodingChosen = jest.fn(); + $scope.delimiterChosen = jest.fn(); + $scope.startRowSet = jest.fn(); + $scope.extraRowSet = jest.fn(); + }); + + test("should re-parse CSV file when settings change", () => { + // Set up file data + $scope.data = { + source: { + data: "Date,Payee,Amount\n2024-01-01,Store,-50.00", + filename: "test.csv", + }, + }; + $scope.currentFilename = "test.csv"; + $scope.filename = "test.csv"; + + global.FileUtils.isExcelFile.mockReturnValue(false); + + // Change settings + $scope.file.chosenEncoding = "ISO-8859-1"; + $scope.file.chosenDelimiter = ";"; + $scope.file.startAtRow = 2; + $scope.file.extraRow = true; + $scope.profile.extraRow = true; + + // Call reparseFile + $scope.reparseFile(); + + // Verify CSV was re-parsed with new settings + expect($scope.data_object.parseCsv).toHaveBeenCalledWith( + $scope.data.source.data, + "ISO-8859-1", + 2, + true, + ";", + ); + + // Verify preview was updated + expect($scope.data_object.converted_json).toHaveBeenCalledWith( + 10, + $scope.ynab_cols, + $scope.ynab_map, + $scope.inverted_outflow, + $scope.inverted_amount, + $scope.fix_dates, + ); + + // Verify settings were saved + expect($scope.encodingChosen).toHaveBeenCalledWith("ISO-8859-1"); + expect($scope.delimiterChosen).toHaveBeenCalledWith(";"); + expect($scope.startRowSet).toHaveBeenCalledWith(2); + expect($scope.extraRowSet).toHaveBeenCalledWith(true); + }); + + test("should re-parse Excel file when settings change", () => { + // Set up file data + $scope.data = { + source: { + data: "excel_binary_data", + filename: "test.xlsx", + }, + }; + $scope.currentFilename = "test.xlsx"; + $scope.filename = "test.xlsx"; + $scope.file.selectedWorksheet = 1; + + global.FileUtils.isExcelFile.mockReturnValue(true); + + // Change settings + $scope.file.chosenEncoding = "UTF-16"; + $scope.file.chosenDelimiter = "auto"; + $scope.file.startAtRow = 3; + $scope.profile.extraRow = false; + + // Call reparseFile + $scope.reparseFile(); + + // Verify Excel was re-parsed with new settings + expect($scope.data_object.parseExcel).toHaveBeenCalledWith( + $scope.data.source.data, + "test.xlsx", + "UTF-16", + 3, + false, + null, // auto delimiter + 1, // worksheet index + ); + + // Verify preview was updated + expect($scope.data_object.converted_json).toHaveBeenCalled(); + }); + + test("should handle re-parse with manual delimiter for Excel", () => { + $scope.data = { + source: { + data: "excel_binary_data", + filename: "test.xlsx", + }, + }; + $scope.currentFilename = "test.xlsx"; + $scope.filename = "test.xlsx"; + global.FileUtils.isExcelFile.mockReturnValue(true); + $scope.file.chosenDelimiter = ","; + + $scope.reparseFile(); + + expect($scope.data_object.parseExcel).toHaveBeenCalledWith( + expect.any(String), + expect.any(String), + expect.any(String), + expect.any(Number), + expect.any(Boolean), + ",", // manual delimiter passed + expect.any(Number), + ); + }); + + test("should not re-parse when no data is loaded", () => { + // No data loaded + $scope.data.source = null; + + $scope.reparseFile(); + + expect($scope.data_object.parseCsv).not.toHaveBeenCalled(); + expect($scope.data_object.parseExcel).not.toHaveBeenCalled(); + }); + + test("should handle errors during re-parse", () => { + // Mock console.error and alert + global.console.error = jest.fn(); + global.alert = jest.fn(); + + $scope.data = { + source: { + data: "invalid data", + filename: "test.csv", + }, + }; + global.FileUtils.isExcelFile.mockReturnValue(false); + + // Make parseCsv throw an error + $scope.data_object.parseCsv.mockImplementation(() => { + throw new Error("Parse error"); + }); + + $scope.reparseFile(); + + expect(global.console.error).toHaveBeenCalledWith( + "Error re-parsing file:", + expect.any(Error), + ); + expect(global.alert).toHaveBeenCalledWith( + "Error re-parsing file: Parse error", + ); + + // Clean up + delete global.console.error; + delete global.alert; + }); + + test("should use currentFilename over filename for file type detection", () => { + $scope.data = { + source: { + data: "some data", + filename: "old.csv", + }, + }; + $scope.currentFilename = "new.xlsx"; + $scope.filename = "old.csv"; + + global.FileUtils.isExcelFile.mockReturnValue(true); + + $scope.reparseFile(); + + // Should check file type with currentFilename first + expect(global.FileUtils.isExcelFile).toHaveBeenCalledWith("new.xlsx"); + expect($scope.data_object.parseExcel).toHaveBeenCalled(); + }); + }); + + describe("Worksheet Selection", () => { + beforeEach(() => { + // Mock FileUtils globally + global.FileUtils = { + isExcelFile: jest.fn().mockReturnValue(true), + }; + + // Mock alert and console.error for error handling tests + global.alert = jest.fn(); + global.console.error = jest.fn(); + + jest.resetModules(); + require("../src/app.js"); + const controllerCalls = mockModule.controller.mock.calls; + const parseControllerCall = controllerCalls.find( + (call) => call[0] === "ParseController", + ); + const controllerFn = parseControllerCall[1]; + controllerFn($scope, $location); + + // Set up Excel file scenario + $scope.currentFilename = "test.xlsx"; + $scope.data = { + source: { + data: "excel_binary_data", + filename: "test.xlsx", + }, + }; + }); + + afterEach(() => { + // Clean up global mocks + delete global.alert; + delete global.console.error; + }); + + test("should handle worksheet selection change", () => { + // Call worksheetChosen with string index (as it comes from ng-value) + $scope.worksheetChosen("2"); + + // Should parse Excel with numeric index + expect($scope.data_object.parseExcel).toHaveBeenCalledWith( + $scope.data.source.data, + "test.xlsx", + $scope.file.chosenEncoding, + $scope.file.startAtRow, + $scope.profile.extraRow, + null, + 2, // Converted to number + ); + + expect($scope.data_object.converted_json).toHaveBeenCalled(); + }); + + test("should handle worksheet selection with numeric index", () => { + // Call with numeric index directly + $scope.worksheetChosen(1); + + expect($scope.data_object.parseExcel).toHaveBeenCalledWith( + expect.any(String), + expect.any(String), + expect.any(String), + expect.any(Number), + expect.any(Boolean), + null, + 1, + ); + }); + + test("should handle errors during worksheet change", () => { + $scope.data_object.parseExcel.mockImplementation(() => { + throw new Error("Worksheet not found"); + }); + + $scope.worksheetChosen("1"); + + expect(global.console.error).toHaveBeenCalledWith( + "Error switching worksheet:", + expect.any(Error), + ); + expect(global.alert).toHaveBeenCalledWith( + "Error switching worksheet: Worksheet not found", + ); + }); + + test("should only process worksheet change for Excel files", () => { + global.FileUtils.isExcelFile.mockReturnValue(false); + $scope.currentFilename = "test.csv"; + + $scope.worksheetChosen("1"); + + // Should not call parseExcel for non-Excel files + expect($scope.data_object.parseExcel).not.toHaveBeenCalled(); + }); + }); + + describe("Auto-matching Configuration", () => { + beforeEach(() => { + // Reset ConfigMatcher mocks + global.ConfigMatcher.getAllConfigurations.mockReturnValue({}); + global.ConfigMatcher.getConfiguration.mockReturnValue(null); + global.ConfigMatcher.findMatchingConfig.mockReturnValue(null); + global.ConfigMatcher.findMatchingConfigWithStartRow.mockReturnValue(null); + global.ConfigMatcher.saveConfiguration.mockReturnValue(null); + global.ConfigMatcher.updateConfiguration.mockReturnValue(false); + global.ConfigMatcher.deleteConfiguration.mockReturnValue(false); + global.ConfigMatcher.renameConfiguration.mockReturnValue(false); + global.ConfigMatcher.incrementUsageCount.mockClear(); + + // Set up data object mock + $scope.data_object.base_json = [ + { Date: "2024-01-01", Payee: "Store", Amount: "-50.00" }, + ]; + $scope.data_object.fields.mockReturnValue([ + "Date", + "Description", + "Amount", + ]); + $scope.data_object.converted_json.mockReturnValue([ + { Date: "2024-01-01", Payee: "Store", Amount: "-50.00" }, + ]); + $scope.filename = "test.csv"; + + // Set up data.source for tryAutoApplyConfig (it now reads raw content) + $scope.data = { + source: { + data: "Date,Description,Amount\n2024-01-01,Store,-50.00", + filename: "test.csv", + }, + }; + }); + + describe("tryAutoApplyConfig", () => { + test("should not attempt matching if data.source is not available", () => { + $scope.data = null; + + $scope.tryAutoApplyConfig(); + + expect( + global.ConfigMatcher.findMatchingConfigWithStartRow, + ).not.toHaveBeenCalled(); + expect($scope.matchedConfig).toBeNull(); + }); + + test("should find and store matching config with high confidence", () => { + const mockConfig = { + id: "test-config-id", + name: "Test Config", + columnFormat: ["Date", "Payee", "Memo", "Amount"], + chosenColumns: { + Date: "Date", + Payee: "Description", + Amount: "Amount", + }, + }; + + global.ConfigMatcher.findMatchingConfigWithStartRow.mockReturnValue({ + configId: "test-config-id", + matchType: "exact", + confidence: 100, + config: mockConfig, + }); + + $scope.tryAutoApplyConfig(); + + expect( + global.ConfigMatcher.findMatchingConfigWithStartRow, + ).toHaveBeenCalledWith( + "Date,Description,Amount\n2024-01-01,Store,-50.00", + "test.csv", + null, + ); + expect($scope.matchedConfig).toBeDefined(); + expect($scope.matchedConfig.confidence).toBe(100); + expect($scope.autoApplied).toBe(true); + }); + + test("should auto-apply config when confidence >= 80", () => { + const mockConfig = { + id: "test-config-id", + columnFormat: ["Date", "Payee", "Memo", "Amount"], + chosenColumns: { Date: "Date", Payee: "Description" }, + chosenEncoding: "windows-1252", + startAtRow: 2, + }; + + global.ConfigMatcher.findMatchingConfigWithStartRow.mockReturnValue({ + configId: "test-config-id", + matchType: "exact", + confidence: 85, + config: mockConfig, + }); + + $scope.tryAutoApplyConfig(); + + expect($scope.autoApplied).toBe(true); + expect($scope.ynab_cols).toEqual(["Date", "Payee", "Memo", "Amount"]); + expect($scope.file.chosenEncoding).toBe("windows-1252"); + expect($scope.file.startAtRow).toBe(2); + }); + + test("should store but not auto-apply config when 60 <= confidence < 80", () => { + const mockConfig = { + id: "test-config-id", + columnFormat: ["Date", "Payee", "Memo", "Amount"], + chosenColumns: { Date: "Date" }, + }; + + global.ConfigMatcher.findMatchingConfigWithStartRow.mockReturnValue({ + configId: "test-config-id", + matchType: "partial", + confidence: 70, + config: mockConfig, + }); + + $scope.tryAutoApplyConfig(); + + expect($scope.matchedConfig).toBeDefined(); + expect($scope.matchedConfig.confidence).toBe(70); + expect($scope.autoApplied).toBe(false); + }); + + test("should not store config when confidence < 60", () => { + global.ConfigMatcher.findMatchingConfigWithStartRow.mockReturnValue({ + configId: "test-config-id", + matchType: "filename", + confidence: 50, + config: {}, + }); + + $scope.tryAutoApplyConfig(); + + expect($scope.matchedConfig).toBeNull(); + expect($scope.autoApplied).toBe(false); + }); + }); + + describe("applyConfig", () => { + test("should apply all config settings to scope", () => { + const config = { + columnFormat: ["Date", "Payee", "Memo", "Amount"], + chosenColumns: { Date: "Trans Date", Payee: "Merchant" }, + chosenEncoding: "ISO-8859-1", + chosenDelimiter: ";", + startAtRow: 3, + extraRow: true, + invertedOutflow: true, + invertedAmount: true, + fixDates: true, + }; + + $scope.matchedConfig = { configId: "test-id" }; + + $scope.applyConfig(config); + + expect($scope.ynab_cols).toEqual(["Date", "Payee", "Memo", "Amount"]); + expect($scope.ynab_map).toEqual({ + Date: "Trans Date", + Payee: "Merchant", + }); + expect($scope.file.chosenEncoding).toBe("ISO-8859-1"); + expect($scope.file.chosenDelimiter).toBe(";"); + expect($scope.file.startAtRow).toBe(3); + expect($scope.file.extraRow).toBe(true); + expect($scope.inverted_outflow).toBe(true); + expect($scope.inverted_amount).toBe(true); + expect($scope.fix_dates).toBe(true); + expect(global.ConfigMatcher.incrementUsageCount).toHaveBeenCalledWith( + "test-id", + ); + }); + + test("should not fail with null config", () => { + expect(() => $scope.applyConfig(null)).not.toThrow(); + }); + + test("should only apply defined properties", () => { + $scope.file.chosenEncoding = "UTF-8"; + $scope.file.startAtRow = 1; + + const config = { + columnFormat: ["Date", "Payee", "Memo", "Amount"], + // No other properties + }; + + $scope.applyConfig(config); + + expect($scope.ynab_cols).toEqual(["Date", "Payee", "Memo", "Amount"]); + expect($scope.file.chosenEncoding).toBe("UTF-8"); // Unchanged + expect($scope.file.startAtRow).toBe(1); // Unchanged + }); + }); + + describe("saveCurrentConfig", () => { + test("should save current settings as configuration", () => { + const savedConfig = { + id: "new-config-id", + name: "My Config", + }; + + global.ConfigMatcher.saveConfiguration.mockReturnValue("new-config-id"); + global.ConfigMatcher.getConfiguration.mockReturnValue(savedConfig); + global.ConfigMatcher.getAllConfigurations.mockReturnValue({ + "new-config-id": savedConfig, + }); + + $scope.ynab_cols = ["Date", "Payee", "Memo", "Amount"]; + $scope.ynab_map = { Date: "Trans Date", Payee: "Merchant" }; + $scope.file.chosenEncoding = "UTF-8"; + $scope.file.chosenDelimiter = ","; + $scope.file.startAtRow = 1; + $scope.file.extraRow = false; + $scope.inverted_outflow = false; + $scope.inverted_amount = false; + + const result = $scope.saveCurrentConfig("My Config"); + + expect(result).toBe("new-config-id"); + expect(global.ConfigMatcher.saveConfiguration).toHaveBeenCalledWith( + ["Date", "Description", "Amount"], + "test.csv", + { + columnFormat: ["Date", "Payee", "Memo", "Amount"], + chosenColumns: { Date: "Trans Date", Payee: "Merchant" }, + chosenEncoding: "UTF-8", + chosenDelimiter: ",", + startAtRow: 1, + extraRow: false, + invertedOutflow: false, + invertedAmount: false, + fixDates: false, + }, + "My Config", + ); + expect($scope.matchedConfig).toBeDefined(); + expect($scope.matchedConfig.configId).toBe("new-config-id"); + }); + + test("should return null if no data loaded", () => { + $scope.data_object.base_json = null; + + const result = $scope.saveCurrentConfig("Test"); + + expect(result).toBeNull(); + expect(global.ConfigMatcher.saveConfiguration).not.toHaveBeenCalled(); + }); + }); + + describe("updateMatchedConfig", () => { + test("should update existing matched configuration", () => { + $scope.matchedConfig = { + configId: "existing-config-id", + config: { name: "Old Config" }, + }; + + global.ConfigMatcher.updateConfiguration.mockReturnValue(true); + global.ConfigMatcher.getConfiguration.mockReturnValue({ + name: "Updated Config", + }); + global.ConfigMatcher.getAllConfigurations.mockReturnValue({}); + + $scope.ynab_cols = ["Date", "Payee", "Memo", "Amount"]; + $scope.file.startAtRow = 2; + + const result = $scope.updateMatchedConfig(); + + expect(result).toBe(true); + expect(global.ConfigMatcher.updateConfiguration).toHaveBeenCalledWith( + "existing-config-id", + expect.objectContaining({ + columnFormat: ["Date", "Payee", "Memo", "Amount"], + startAtRow: 2, + }), + ); + }); + + test("should return false if no matched config", () => { + $scope.matchedConfig = null; + + const result = $scope.updateMatchedConfig(); + + expect(result).toBe(false); + expect(global.ConfigMatcher.updateConfiguration).not.toHaveBeenCalled(); + }); + }); + + describe("deleteConfig", () => { + test("should delete configuration and clear matched config if same", () => { + $scope.matchedConfig = { + configId: "config-to-delete", + config: {}, + }; + $scope.autoApplied = true; + + global.ConfigMatcher.deleteConfiguration.mockReturnValue(true); + global.ConfigMatcher.getAllConfigurations.mockReturnValue({}); + + $scope.deleteConfig("config-to-delete"); + + expect(global.ConfigMatcher.deleteConfiguration).toHaveBeenCalledWith( + "config-to-delete", + ); + expect($scope.matchedConfig).toBeNull(); + expect($scope.autoApplied).toBe(false); + }); + + test("should not clear matched config if different config deleted", () => { + $scope.matchedConfig = { + configId: "other-config", + config: {}, + }; + + global.ConfigMatcher.deleteConfiguration.mockReturnValue(true); + global.ConfigMatcher.getAllConfigurations.mockReturnValue({}); + + $scope.deleteConfig("config-to-delete"); + + expect($scope.matchedConfig).not.toBeNull(); + expect($scope.matchedConfig.configId).toBe("other-config"); + }); + }); + + describe("renameConfig", () => { + test("should rename configuration and update matched config name", () => { + $scope.matchedConfig = { + configId: "config-id", + config: { name: "Old Name" }, + }; + + global.ConfigMatcher.renameConfiguration.mockReturnValue(true); + global.ConfigMatcher.getAllConfigurations.mockReturnValue({}); + + $scope.renameConfig("config-id", "New Name"); + + expect(global.ConfigMatcher.renameConfiguration).toHaveBeenCalledWith( + "config-id", + "New Name", + ); + expect($scope.matchedConfig.config.name).toBe("New Name"); + }); + }); + + describe("hasSavedConfigs", () => { + test("should return true when configs exist", () => { + $scope.savedConfigs = { + "config-1": {}, + "config-2": {}, + }; + + expect($scope.hasSavedConfigs()).toBe(true); + }); + + test("should return false when no configs exist", () => { + $scope.savedConfigs = {}; + + expect($scope.hasSavedConfigs()).toBe(false); + }); + }); + + describe("dismissAutoApply", () => { + test("should set autoApplied to false", () => { + $scope.autoApplied = true; + + $scope.dismissAutoApply(); + + expect($scope.autoApplied).toBe(false); + }); + }); + + describe("tryAutoApplyConfigForExcel", () => { + beforeEach(() => { + $scope.data_object.fields.mockReturnValue([ + "Date", + "Description", + "Amount", + ]); + $scope.data_object.base_json = [{ Date: "2024-01-01" }]; + $scope.filename = "test.xlsx"; + $scope.reparseFile = jest.fn(); + }); + + test("should return early if data_object is not available", () => { + $scope.data_object = null; + + $scope.tryAutoApplyConfigForExcel(); + + expect(global.ConfigMatcher.findMatchingConfig).not.toHaveBeenCalled(); + }); + + test("should return early if headers are empty", () => { + $scope.data_object.fields.mockReturnValue([]); + + $scope.tryAutoApplyConfigForExcel(); + + expect(global.ConfigMatcher.findMatchingConfig).not.toHaveBeenCalled(); + }); + + test("should auto-apply high confidence match with same startAtRow", () => { + const mockConfig = { + id: "excel-config", + columnFormat: ["Date", "Payee", "Memo", "Amount"], + chosenColumns: { Date: "Date" }, + startAtRow: 1, + }; + + global.ConfigMatcher.findMatchingConfig.mockReturnValue({ + configId: "excel-config", + matchType: "exact", + confidence: 100, + config: mockConfig, + }); + + $scope.file.startAtRow = 1; + + $scope.tryAutoApplyConfigForExcel(); + + expect($scope.matchedConfig).toBeDefined(); + expect($scope.autoApplied).toBe(true); + expect($scope.reparseFile).not.toHaveBeenCalled(); + }); + + test("should re-parse when matched config has different startAtRow", () => { + const mockConfig = { + id: "excel-config", + columnFormat: ["Date", "Payee", "Memo", "Amount"], + chosenColumns: { Date: "Date" }, + startAtRow: 3, + }; + + global.ConfigMatcher.findMatchingConfig.mockReturnValue({ + configId: "excel-config", + matchType: "exact", + confidence: 100, + config: mockConfig, + }); + + $scope.file.startAtRow = 1; + + $scope.tryAutoApplyConfigForExcel(); + + expect($scope.reparseFile).toHaveBeenCalled(); + expect($scope.file.startAtRow).toBe(3); + expect($scope.autoApplied).toBe(true); + }); + + test("should store medium confidence match without auto-applying", () => { + const mockConfig = { + id: "excel-config", + columnFormat: ["Date", "Payee", "Memo", "Amount"], + chosenColumns: { Date: "Date" }, + }; + + global.ConfigMatcher.findMatchingConfig.mockReturnValue({ + configId: "excel-config", + matchType: "partial", + confidence: 70, + config: mockConfig, + }); + + $scope.tryAutoApplyConfigForExcel(); + + expect($scope.matchedConfig).toBeDefined(); + expect($scope.autoApplied).toBe(false); + }); + + test("should try different startAtRow values from saved configs", () => { + // First match returns low confidence + global.ConfigMatcher.findMatchingConfig + .mockReturnValueOnce(null) + .mockReturnValueOnce({ + configId: "found-config", + matchType: "exact", + confidence: 100, + config: { + columnFormat: ["Date", "Payee", "Memo", "Amount"], + startAtRow: 3, + }, + }); + + // Saved configs with different startAtRow values + global.ConfigMatcher.getAllConfigurations.mockReturnValue({ + "config-1": { startAtRow: 1 }, + "config-2": { startAtRow: 3 }, + }); + + $scope.file.startAtRow = 1; + + $scope.tryAutoApplyConfigForExcel(); + + expect($scope.reparseFile).toHaveBeenCalled(); + expect($scope.autoApplied).toBe(true); + }); + + test("should reset to profile default when no match found after trying all rows", () => { + global.ConfigMatcher.findMatchingConfig.mockReturnValue(null); + global.ConfigMatcher.getAllConfigurations.mockReturnValue({ + "config-1": { startAtRow: 2 }, + }); + + $scope.file.startAtRow = 1; + $scope.profile.startAtRow = 1; + + $scope.tryAutoApplyConfigForExcel(); + + // Should have tried row 2, then reset back to profile default (1) + expect($scope.file.startAtRow).toBe(1); + }); + + test("should skip already tried startAtRow values", () => { + global.ConfigMatcher.findMatchingConfig.mockReturnValue(null); + global.ConfigMatcher.getAllConfigurations.mockReturnValue({ + "config-1": { startAtRow: 1 }, // Same as current + "config-2": { startAtRow: 1 }, // Duplicate + }); + + $scope.file.startAtRow = 1; + $scope.profile.startAtRow = 1; + + $scope.tryAutoApplyConfigForExcel(); + + // Should not call reparseFile since all configs have same startAtRow + expect($scope.reparseFile).not.toHaveBeenCalled(); + }); + }); + + describe("saveConfigWithName", () => { + beforeEach(() => { + global.prompt = jest.fn(); + $scope.saveCurrentConfig = jest.fn(); + }); + + afterEach(() => { + delete global.prompt; + }); + + test("should prompt for name and save config", () => { + global.prompt.mockReturnValue("My Custom Config"); + + $scope.saveConfigWithName(); + + expect(global.prompt).toHaveBeenCalled(); + expect($scope.saveCurrentConfig).toHaveBeenCalledWith( + "My Custom Config", + ); + }); + + test("should not save if user cancels prompt", () => { + global.prompt.mockReturnValue(null); + + $scope.saveConfigWithName(); + + expect($scope.saveCurrentConfig).not.toHaveBeenCalled(); + }); + + test("should prevent default event if provided", () => { + const mockEvent = { preventDefault: jest.fn() }; + global.prompt.mockReturnValue("Test"); + + $scope.saveConfigWithName(mockEvent); + + expect(mockEvent.preventDefault).toHaveBeenCalled(); + }); + + test("should use matched config name as default when available", () => { + $scope.matchedConfig = { + config: { name: "Existing Config Name" }, + }; + global.prompt.mockReturnValue("New Name"); + + $scope.saveConfigWithName(); + + expect(global.prompt).toHaveBeenCalledWith( + "Enter a name for this configuration:", + "Existing Config Name", + ); + }); + + test("should use filename as default when no matched config", () => { + $scope.matchedConfig = null; + $scope.filename = "my_bank_statement.csv"; + global.prompt.mockReturnValue("New Name"); + + $scope.saveConfigWithName(); + + expect(global.prompt).toHaveBeenCalledWith( + "Enter a name for this configuration:", + "my_bank_statement.csv", + ); + }); + }); + + describe("promptRenameConfig", () => { + beforeEach(() => { + global.prompt = jest.fn(); + $scope.renameConfig = jest.fn(); + }); + + afterEach(() => { + delete global.prompt; + }); + + test("should prompt for new name and rename config", () => { + global.ConfigMatcher.getConfiguration.mockReturnValue({ + name: "Old Name", + }); + global.prompt.mockReturnValue("New Name"); + + $scope.promptRenameConfig("config-id"); + + expect(global.ConfigMatcher.getConfiguration).toHaveBeenCalledWith( + "config-id", + ); + expect(global.prompt).toHaveBeenCalledWith( + "Enter a new name:", + "Old Name", + ); + expect($scope.renameConfig).toHaveBeenCalledWith( + "config-id", + "New Name", + ); + }); + + test("should not rename if config not found", () => { + global.ConfigMatcher.getConfiguration.mockReturnValue(null); + + $scope.promptRenameConfig("nonexistent-id"); + + expect(global.prompt).not.toHaveBeenCalled(); + expect($scope.renameConfig).not.toHaveBeenCalled(); + }); + + test("should not rename if user cancels prompt", () => { + global.ConfigMatcher.getConfiguration.mockReturnValue({ + name: "Old Name", + }); + global.prompt.mockReturnValue(null); + + $scope.promptRenameConfig("config-id"); + + expect($scope.renameConfig).not.toHaveBeenCalled(); + }); + + test("should not rename if new name is same as old name", () => { + global.ConfigMatcher.getConfiguration.mockReturnValue({ + name: "Same Name", + }); + global.prompt.mockReturnValue("Same Name"); + + $scope.promptRenameConfig("config-id"); + + expect($scope.renameConfig).not.toHaveBeenCalled(); + }); + }); + + describe("downloadFile auto-save", () => { + beforeEach(() => { + const mockAnchor = { + href: "", + target: "", + download: "", + click: jest.fn(), + }; + global.document.createElement = jest.fn(() => mockAnchor); + global.document.body.appendChild = jest.fn(); + + $scope.data_object.converted_csv = jest.fn(() => "Date,Payee,Amount"); + + const mockDate = new Date("2024-01-01"); + mockDate.yyyymmdd = jest.fn(() => "20240101"); + jest.spyOn(global, "Date").mockImplementation(() => mockDate); + }); + + afterEach(() => { + global.Date.mockRestore(); + }); + + test("should save new config on download when no matched config", () => { + $scope.matchedConfig = null; + global.ConfigMatcher.saveConfiguration.mockReturnValue("new-config"); + global.ConfigMatcher.getConfiguration.mockReturnValue({}); + global.ConfigMatcher.getAllConfigurations.mockReturnValue({}); + + $scope.downloadFile(); + + expect(global.ConfigMatcher.saveConfiguration).toHaveBeenCalled(); + }); + + test("should update existing config on download when matched", () => { + $scope.matchedConfig = { + configId: "existing-config", + config: {}, + }; + global.ConfigMatcher.updateConfiguration.mockReturnValue(true); + global.ConfigMatcher.getConfiguration.mockReturnValue({}); + global.ConfigMatcher.getAllConfigurations.mockReturnValue({}); + + $scope.downloadFile(); + + expect(global.ConfigMatcher.updateConfiguration).toHaveBeenCalledWith( + "existing-config", + expect.any(Object), + ); + }); + }); + + describe("auto-matching in data.source watcher", () => { + test("should apply matching config for CSV files before parsing", () => { + // Set up watchers + const watchCallbacks = {}; + $scope.$watch.mockImplementation((expr, callback) => { + watchCallbacks[expr] = callback; + }); + + // Re-initialize to capture watch callbacks + jest.resetModules(); + require("../src/app.js"); + const controllerCalls = mockModule.controller.mock.calls; + const parseControllerCall = controllerCalls.find( + (call) => call[0] === "ParseController", + ); + const controllerFn = parseControllerCall[1]; + controllerFn($scope, $location); + + // Set up CSV file scenario + $scope.data_object.isExcelFile.mockReturnValue(false); + $scope.data_object.base_json = [{ Date: "2024-01-01" }]; + + // Set up matching config with different startAtRow + const mockConfig = { + id: "auto-match-id", + columnFormat: ["Date", "Payee", "Memo", "Amount"], + chosenColumns: { Date: "Date" }, + startAtRow: 3, + }; + global.ConfigMatcher.findMatchingConfigWithStartRow.mockReturnValue({ + configId: "auto-match-id", + matchType: "exact", + confidence: 100, + config: mockConfig, + detectedStartAtRow: 3, + }); + + const csvData = { + data: "Header1\nHeader2\nDate,Payee,Amount\n2024-01-01,Store,-50.00", + filename: "test.csv", + }; + + watchCallbacks["data.source"](csvData, null); + + expect( + global.ConfigMatcher.findMatchingConfigWithStartRow, + ).toHaveBeenCalled(); + expect($scope.matchedConfig).toBeDefined(); + expect($scope.autoApplied).toBe(true); + expect($scope.file.startAtRow).toBe(3); + }); + + test("should reset auto-match state before trying to match", () => { + // Set up watchers + const watchCallbacks = {}; + $scope.$watch.mockImplementation((expr, callback) => { + watchCallbacks[expr] = callback; + }); + + // Re-initialize to capture watch callbacks + jest.resetModules(); + require("../src/app.js"); + const controllerCalls = mockModule.controller.mock.calls; + const parseControllerCall = controllerCalls.find( + (call) => call[0] === "ParseController", + ); + const controllerFn = parseControllerCall[1]; + controllerFn($scope, $location); + + // Set initial state + $scope.matchedConfig = { configId: "old-config" }; + $scope.autoApplied = true; + $scope.data_object.isExcelFile.mockReturnValue(false); + + // No matching config this time + global.ConfigMatcher.findMatchingConfigWithStartRow.mockReturnValue( + null, + ); + + const csvData = { + data: "NewHeader1,NewHeader2\nval1,val2", + filename: "different.csv", + }; + + watchCallbacks["data.source"](csvData, null); + + // Should have been reset + expect($scope.matchedConfig).toBeNull(); + expect($scope.autoApplied).toBe(false); + }); + }); + }); + + describe("Fix Dates Auto-Detection", () => { + test("should auto-enable fix_dates when short year dates detected on file load", () => { + // Set up watchers + const watchCallbacks = {}; + $scope.$watch.mockImplementation((expr, callback, deep) => { + watchCallbacks[expr] = callback; + }); + + // Re-initialize to capture watch callbacks + jest.resetModules(); + require("../src/app.js"); + const controllerCalls = mockModule.controller.mock.calls; + const parseControllerCall = controllerCalls.find( + (call) => call[0] === "ParseController", + ); + const controllerFn = parseControllerCall[1]; + controllerFn($scope, $location); + + // Set up data object with hasShortYearDates + $scope.data_object.hasShortYearDates = jest.fn(() => true); + $scope.ynab_map = { Date: "Date", Payee: "Description" }; + + // Simulate file data change + const csvData = { + data: "Date,Description\n01/15/24,Purchase", + filename: "test.csv", + }; + + watchCallbacks["data.source"](csvData, null); + + expect($scope.data_object.hasShortYearDates).toHaveBeenCalledWith("Date"); + expect($scope.fix_dates).toBe(true); + }); + + test("should auto-enable fix_dates when Date mapping changes to column with short years", () => { + // Set up watchers + const watchCallbacks = {}; + $scope.$watch.mockImplementation((expr, callback, deep) => { + watchCallbacks[expr] = callback; + }); + + // Re-initialize to capture watch callbacks + jest.resetModules(); + require("../src/app.js"); + const controllerCalls = mockModule.controller.mock.calls; + const parseControllerCall = controllerCalls.find( + (call) => call[0] === "ParseController", + ); + const controllerFn = parseControllerCall[1]; + controllerFn($scope, $location); + + // Set up data object with hasShortYearDates + $scope.data_object.hasShortYearDates = jest.fn(() => true); + + // Simulate mapping change where Date column changes + const newMapping = { Date: "Trans Date", Payee: "Merchant" }; + const oldMapping = { Date: "Other", Payee: "Merchant" }; + watchCallbacks["ynab_map"](newMapping, oldMapping); + + expect($scope.data_object.hasShortYearDates).toHaveBeenCalledWith( + "Trans Date", + ); + expect($scope.fix_dates).toBe(true); + }); + + test("should include fixDates in saveCurrentConfig settings", () => { + global.ConfigMatcher.saveConfiguration.mockReturnValue("config-id"); + global.ConfigMatcher.getConfiguration.mockReturnValue({ + id: "config-id", + name: "Test", + }); + global.ConfigMatcher.getAllConfigurations.mockReturnValue({}); + + $scope.data_object.base_json = { + data: [], + meta: { fields: ["Date", "Amount"] }, + }; + $scope.data_object.fields = jest.fn(() => ["Date", "Amount"]); + $scope.fix_dates = true; + + $scope.saveCurrentConfig("Test"); + + expect(global.ConfigMatcher.saveConfiguration).toHaveBeenCalledWith( + ["Date", "Amount"], + null, + expect.objectContaining({ + fixDates: true, + }), + "Test", + ); + }); + + test("should include fixDates in updateMatchedConfig settings", () => { + $scope.matchedConfig = { + configId: "existing-id", + config: { name: "Old" }, + }; + + global.ConfigMatcher.updateConfiguration.mockReturnValue(true); + global.ConfigMatcher.getConfiguration.mockReturnValue({ + name: "Updated", + }); + global.ConfigMatcher.getAllConfigurations.mockReturnValue({}); + + $scope.fix_dates = true; + + $scope.updateMatchedConfig(); + + expect(global.ConfigMatcher.updateConfiguration).toHaveBeenCalledWith( + "existing-id", + expect.objectContaining({ + fixDates: true, + }), + ); + }); + }); +}); diff --git a/tests/config_matcher.test.js b/tests/config_matcher.test.js new file mode 100644 index 0000000..d9f94dc --- /dev/null +++ b/tests/config_matcher.test.js @@ -0,0 +1,1161 @@ +const fs = require("fs"); +const path = require("path"); + +describe("ConfigMatcher", () => { + let ConfigMatcher; + let mockStore; + + beforeEach(() => { + // Create fresh store for each test + mockStore = {}; + + // Create completely new localStorage mock with jest.fn() wrappers + const mockGetItem = jest.fn((key) => mockStore[key] || null); + const mockSetItem = jest.fn((key, value) => { + mockStore[key] = value; + }); + const mockRemoveItem = jest.fn((key) => { + delete mockStore[key]; + }); + const mockClear = jest.fn(() => { + mockStore = {}; + }); + + // Override global localStorage + Object.defineProperty(global, "localStorage", { + value: { + getItem: mockGetItem, + setItem: mockSetItem, + removeItem: mockRemoveItem, + clear: mockClear, + }, + writable: true, + configurable: true, + }); + + // Mock PapaParse for extractHeadersFromContent + global.Papa = { + parse: jest.fn((content, config) => { + // Simple CSV parsing for tests + let lines = content.split(/\r\n|\n|\r/); + + // Apply beforeFirstChunk if provided + if (config.beforeFirstChunk) { + const transformed = config.beforeFirstChunk(content); + lines = transformed.split(/\r\n|\n|\r/); + } + + // Check if content is empty after processing + if (lines.length === 0 || (lines.length === 1 && lines[0] === "")) { + return { meta: { fields: [] }, data: [] }; + } + + // Get header line + let headerLine = lines[0] || ""; + + // Return empty if line is empty + if (!headerLine || headerLine.trim() === "") { + return { meta: { fields: [] }, data: [] }; + } + + // Remove BOM if present + if (headerLine.charCodeAt(0) === 0xfeff) { + headerLine = headerLine.substring(1); + } + + // Auto-detect delimiter if not specified + let delimiter = config.delimiter; + if (!delimiter) { + // Count occurrences of common delimiters + const commaCount = (headerLine.match(/,/g) || []).length; + const semicolonCount = (headerLine.match(/;/g) || []).length; + const tabCount = (headerLine.match(/\t/g) || []).length; + const pipeCount = (headerLine.match(/\|/g) || []).length; + + const maxCount = Math.max( + commaCount, + semicolonCount, + tabCount, + pipeCount, + ); + if (maxCount === 0) { + delimiter = ","; + } else if (semicolonCount === maxCount) { + delimiter = ";"; + } else if (tabCount === maxCount) { + delimiter = "\t"; + } else if (pipeCount === maxCount) { + delimiter = "|"; + } else { + delimiter = ","; + } + } + + // Split headers, handling quotes + let headers = []; + let current = ""; + let inQuotes = false; + for (let i = 0; i < headerLine.length; i++) { + const char = headerLine[i]; + if (char === '"' && (i === 0 || headerLine[i - 1] !== "\\")) { + inQuotes = !inQuotes; + } else if (char === delimiter && !inQuotes) { + headers.push(current.trim().replace(/^["']|["']$/g, "")); + current = ""; + } else { + current += char; + } + } + headers.push(current.trim().replace(/^["']|["']$/g, "")); + + // Apply transformHeader if provided + if (config.transformHeader) { + headers = headers.map(config.transformHeader); + } + + return { + meta: { fields: headers }, + data: [], + }; + }), + }; + + // Clear module cache and re-require + delete require.cache[require.resolve("../src/config_matcher.js")]; + ConfigMatcher = require("../src/config_matcher.js"); + }); + + // ============================================ + // Fingerprint Generation Tests + // ============================================ + + describe("generateFingerprint", () => { + test("should generate consistent fingerprint for headers", () => { + const headers = ["Date", "Description", "Amount"]; + const fp1 = ConfigMatcher.generateFingerprint(headers); + const fp2 = ConfigMatcher.generateFingerprint(headers); + + expect(fp1).toBe(fp2); + expect(fp1).toMatch(/^fp_[a-z0-9]+$/); + }); + + test("should generate same fingerprint for headers in different order", () => { + const headers1 = ["Date", "Description", "Amount"]; + const headers2 = ["Amount", "Date", "Description"]; + const headers3 = ["Description", "Amount", "Date"]; + + const fp1 = ConfigMatcher.generateFingerprint(headers1); + const fp2 = ConfigMatcher.generateFingerprint(headers2); + const fp3 = ConfigMatcher.generateFingerprint(headers3); + + expect(fp1).toBe(fp2); + expect(fp1).toBe(fp3); + }); + + test("should normalize case when generating fingerprint", () => { + const headers1 = ["Date", "Description", "Amount"]; + const headers2 = ["DATE", "DESCRIPTION", "AMOUNT"]; + const headers3 = ["date", "description", "amount"]; + + const fp1 = ConfigMatcher.generateFingerprint(headers1); + const fp2 = ConfigMatcher.generateFingerprint(headers2); + const fp3 = ConfigMatcher.generateFingerprint(headers3); + + expect(fp1).toBe(fp2); + expect(fp1).toBe(fp3); + }); + + test("should handle headers with special characters", () => { + const headers = ["Konto #", "Belopp", "Datum"]; + const fp = ConfigMatcher.generateFingerprint(headers); + + expect(fp).toBeTruthy(); + expect(fp).toMatch(/^fp_[a-z0-9]+$/); + }); + + test("should return null for empty headers", () => { + expect(ConfigMatcher.generateFingerprint([])).toBeNull(); + expect(ConfigMatcher.generateFingerprint(null)).toBeNull(); + expect(ConfigMatcher.generateFingerprint(undefined)).toBeNull(); + }); + + test("should handle headers with whitespace", () => { + const headers1 = ["Date", "Description", "Amount"]; + const headers2 = [" Date ", " Description ", " Amount "]; + + const fp1 = ConfigMatcher.generateFingerprint(headers1); + const fp2 = ConfigMatcher.generateFingerprint(headers2); + + expect(fp1).toBe(fp2); + }); + + test("should filter out empty headers", () => { + const headers1 = ["Date", "Description", "Amount"]; + const headers2 = ["Date", "", "Description", " ", "Amount"]; + + const fp1 = ConfigMatcher.generateFingerprint(headers1); + const fp2 = ConfigMatcher.generateFingerprint(headers2); + + expect(fp1).toBe(fp2); + }); + + test("should generate different fingerprints for different headers", () => { + const headers1 = ["Date", "Description", "Amount"]; + const headers2 = ["Post Date", "Description", "Withdrawals", "Deposits"]; + + const fp1 = ConfigMatcher.generateFingerprint(headers1); + const fp2 = ConfigMatcher.generateFingerprint(headers2); + + expect(fp1).not.toBe(fp2); + }); + + test("should return null when all headers are empty after filtering", () => { + const headers = ["", " ", "\t"]; + const fp = ConfigMatcher.generateFingerprint(headers); + + expect(fp).toBeNull(); + }); + }); + + describe("jaccardSimilarity internal function", () => { + test("should return 100 for two empty sets when matching empty headers against empty originalHeaders", () => { + // Set up a config with empty originalHeaders and chosenColumns + const storedData = { + configs: { + fp_123: { + id: "fp_123", + name: "Empty Config", + chosenColumns: {}, + originalHeaders: [], + }, + }, + }; + mockStore.knownConfigurations = JSON.stringify(storedData); + + // Match with empty headers - both sets become empty, which returns 100 similarity + const match = ConfigMatcher.findMatchingConfig([], "test.csv"); + + // Should get a partial match with 100% confidence (two empty sets are considered identical) + expect(match).not.toBeNull(); + expect(match.matchType).toBe("partial"); + expect(match.confidence).toBe(100); + }); + }); + + // ============================================ + // Filename Pattern Extraction Tests + // ============================================ + + describe("extractFilenamePatterns", () => { + test("should extract patterns from chase_statement_2024.csv", () => { + const patterns = ConfigMatcher.extractFilenamePatterns( + "chase_statement_2024.csv", + ); + + expect(patterns).toContain("chase"); + expect(patterns).toContain("statement"); + // Should not contain numbers + expect(patterns).not.toContain("2024"); + }); + + test("should extract patterns from kontoutdrag 20251227-0654.csv", () => { + const patterns = ConfigMatcher.extractFilenamePatterns( + "kontoutdrag 20251227-0654.csv", + ); + + expect(patterns).toContain("kontoutdrag"); + // Should not contain date numbers + expect(patterns).not.toContain("20251227"); + expect(patterns).not.toContain("0654"); + }); + + test("should extract patterns from activity(25).csv", () => { + const patterns = + ConfigMatcher.extractFilenamePatterns("activity(25).csv"); + + expect(patterns).toContain("activity"); + // Should not contain (25) + expect(patterns).not.toContain("25"); + }); + + test("should handle filenames with underscores and hyphens", () => { + const patterns = ConfigMatcher.extractFilenamePatterns( + "wells_fargo-checking_2025.csv", + ); + + expect(patterns).toContain("wells"); + expect(patterns).toContain("fargo"); + expect(patterns).toContain("checking"); + }); + + test("should handle various extensions", () => { + expect(ConfigMatcher.extractFilenamePatterns("data.csv")).toContain( + "data", + ); + expect(ConfigMatcher.extractFilenamePatterns("data.xlsx")).toContain( + "data", + ); + expect(ConfigMatcher.extractFilenamePatterns("data.xls")).toContain( + "data", + ); + expect(ConfigMatcher.extractFilenamePatterns("data.xlsm")).toContain( + "data", + ); + }); + + test("should return empty array for empty or null input", () => { + expect(ConfigMatcher.extractFilenamePatterns("")).toEqual([]); + expect(ConfigMatcher.extractFilenamePatterns(null)).toEqual([]); + expect(ConfigMatcher.extractFilenamePatterns(undefined)).toEqual([]); + }); + + test("should skip very short patterns (< 3 chars)", () => { + const patterns = ConfigMatcher.extractFilenamePatterns("my_a_b_data.csv"); + + expect(patterns).toContain("data"); + expect(patterns).not.toContain("my"); + expect(patterns).not.toContain("a"); + expect(patterns).not.toContain("b"); + }); + + test("should limit to 5 patterns max", () => { + const patterns = ConfigMatcher.extractFilenamePatterns( + "one_two_three_four_five_six_seven_eight.csv", + ); + + expect(patterns.length).toBeLessThanOrEqual(5); + }); + }); + + // ============================================ + // Header Extraction from Content Tests + // ============================================ + + describe("extractHeadersFromContent", () => { + test("should extract headers from line 1 by default", () => { + const content = "Date,Description,Amount\n2024-01-01,Test,-50"; + const headers = ConfigMatcher.extractHeadersFromContent(content); + + expect(headers).toEqual(["Date", "Description", "Amount"]); + }); + + test("should extract headers from specified row", () => { + const content = + "Account Statement\nGenerated: 2025-01-10\nDate,Payee,Amount\n2025-01-05,Test,-50"; + const headers = ConfigMatcher.extractHeadersFromContent(content, 3); + + expect(headers).toEqual(["Date", "Payee", "Amount"]); + }); + + test("should auto-detect semicolon delimiter", () => { + const content = + "Booking date;Value date;Amount\n2024-01-01;2024-01-01;-50"; + const headers = ConfigMatcher.extractHeadersFromContent( + content, + 1, + "auto", + ); + + expect(headers).toEqual(["Booking date", "Value date", "Amount"]); + }); + + test("should use specified delimiter", () => { + const content = "Date;Description;Amount\n2024-01-01;Test;-50"; + const headers = ConfigMatcher.extractHeadersFromContent(content, 1, ";"); + + expect(headers).toEqual(["Date", "Description", "Amount"]); + }); + + test("should handle BOM character", () => { + const content = "\uFEFFDate,Description,Amount\n2024-01-01,Test,-50"; + const headers = ConfigMatcher.extractHeadersFromContent(content); + + expect(headers).toEqual(["Date", "Description", "Amount"]); + }); + + test("should handle different line endings", () => { + const contentUnix = "Date,Description,Amount\n2024-01-01,Test,-50"; + const contentWindows = "Date,Description,Amount\r\n2024-01-01,Test,-50"; + const contentMac = "Date,Description,Amount\r2024-01-01,Test,-50"; + + expect(ConfigMatcher.extractHeadersFromContent(contentUnix)).toEqual([ + "Date", + "Description", + "Amount", + ]); + expect(ConfigMatcher.extractHeadersFromContent(contentWindows)).toEqual([ + "Date", + "Description", + "Amount", + ]); + expect(ConfigMatcher.extractHeadersFromContent(contentMac)).toEqual([ + "Date", + "Description", + "Amount", + ]); + }); + + test("should return empty array for invalid input", () => { + expect(ConfigMatcher.extractHeadersFromContent("")).toEqual([]); + expect(ConfigMatcher.extractHeadersFromContent(null)).toEqual([]); + expect(ConfigMatcher.extractHeadersFromContent(undefined)).toEqual([]); + }); + + test("should return empty array if row doesn't exist", () => { + const content = "Date,Amount\n2024-01-01,-50"; + expect(ConfigMatcher.extractHeadersFromContent(content, 10)).toEqual([]); + }); + + test("should handle quoted headers", () => { + const content = '"Date","Description","Amount"\n2024-01-01,Test,-50'; + const headers = ConfigMatcher.extractHeadersFromContent(content); + + expect(headers).toEqual(["Date", "Description", "Amount"]); + }); + + test("should handle duplicate headers by adding suffix", () => { + const content = "Date,Amount,Date,Amount\n2024-01-01,-50,2024-01-02,-100"; + const headers = ConfigMatcher.extractHeadersFromContent(content); + + // Should have renamed duplicates + expect(headers).toContain("Date"); + expect(headers).toContain("Amount"); + expect(headers).toContain("Date (1)"); + expect(headers).toContain("Amount (1)"); + }); + + test("should handle empty headers by naming them 'Unnamed column'", () => { + const content = "Date,,Amount\n2024-01-01,value,-50"; + const headers = ConfigMatcher.extractHeadersFromContent(content); + + expect(headers).toContain("Date"); + expect(headers).toContain("Unnamed column"); + expect(headers).toContain("Amount"); + }); + + test("should handle multiple empty headers with suffix", () => { + const content = "Date,,,Amount\n2024-01-01,a,b,-50"; + const headers = ConfigMatcher.extractHeadersFromContent(content); + + expect(headers).toContain("Date"); + expect(headers).toContain("Unnamed column"); + expect(headers).toContain("Unnamed column (1)"); + expect(headers).toContain("Amount"); + }); + + test("should extract headers from real test files", () => { + // Read chase_statement_2024.csv + const chaseContent = fs.readFileSync( + path.join(__dirname, "../test_files/chase_statement_2024.csv"), + "utf8", + ); + const chaseHeaders = + ConfigMatcher.extractHeadersFromContent(chaseContent); + expect(chaseHeaders).toEqual([ + "Date", + "Description", + "Amount", + "Balance", + ]); + + // Read metadata_header_row3.csv + const metadataContent = fs.readFileSync( + path.join(__dirname, "../test_files/metadata_header_row3.csv"), + "utf8", + ); + const metadataHeaders = ConfigMatcher.extractHeadersFromContent( + metadataContent, + 3, + ); + expect(metadataHeaders).toEqual(["Date", "Payee", "Amount"]); + }); + }); + + // ============================================ + // CRUD Operations Tests + // ============================================ + + describe("saveConfiguration", () => { + test("should save configuration and return fingerprint", () => { + const headers = ["Date", "Description", "Amount"]; + const settings = { + columnFormat: ["Date", "Payee", "Memo", "Amount"], + chosenColumns: { Date: "Date", Payee: "Description", Amount: "Amount" }, + chosenEncoding: "UTF-8", + chosenDelimiter: ",", + startAtRow: 1, + extraRow: false, + invertedOutflow: false, + invertedAmount: false, + }; + + const configId = ConfigMatcher.saveConfiguration( + headers, + "chase_statement_2024.csv", + settings, + ); + + expect(configId).toBeTruthy(); + expect(configId).toMatch(/^fp_[a-z0-9]+$/); + expect(global.localStorage.setItem).toHaveBeenCalled(); + }); + + test("should persist invertedAmount setting", () => { + const headers = ["Date", "Description", "Amount"]; + const settings = { + columnFormat: ["Date", "Payee", "Memo", "Amount"], + chosenColumns: { Date: "Date", Payee: "Description", Amount: "Amount" }, + invertedAmount: true, + }; + + ConfigMatcher.saveConfiguration(headers, "test.csv", settings); + + const savedData = JSON.parse(mockStore.knownConfigurations); + const config = Object.values(savedData.configs)[0]; + + expect(config.invertedAmount).toBe(true); + }); + + test("should save configuration with custom name", () => { + const headers = ["Date", "Description", "Amount"]; + const settings = { + columnFormat: ["Date", "Payee", "Memo", "Amount"], + chosenColumns: { Date: "Date", Payee: "Description", Amount: "Amount" }, + }; + + ConfigMatcher.saveConfiguration( + headers, + "chase.csv", + settings, + "My Chase Account", + ); + + const savedData = JSON.parse(mockStore.knownConfigurations); + const config = Object.values(savedData.configs)[0]; + + expect(config.name).toBe("My Chase Account"); + }); + + test("should generate default name from filename", () => { + const headers = ["Date", "Description", "Amount"]; + const settings = { + columnFormat: ["Date", "Payee", "Memo", "Amount"], + chosenColumns: {}, + }; + + ConfigMatcher.saveConfiguration( + headers, + "chase_statement_2024.csv", + settings, + ); + + const savedData = JSON.parse(mockStore.knownConfigurations); + const config = Object.values(savedData.configs)[0]; + + expect(config.name).toContain("chase"); + }); + + test("should return null for empty headers", () => { + const configId = ConfigMatcher.saveConfiguration([], "test.csv", {}); + expect(configId).toBeNull(); + }); + + test("should store originalHeaders for partial matching", () => { + const headers = ["Date", "Description", "Amount"]; + const settings = { + columnFormat: ["Date", "Payee", "Memo", "Amount"], + chosenColumns: {}, + }; + + ConfigMatcher.saveConfiguration(headers, "test.csv", settings); + + const savedData = JSON.parse(mockStore.knownConfigurations); + const config = Object.values(savedData.configs)[0]; + + expect(config.originalHeaders).toEqual(headers); + }); + + test("should increment useCount on update", () => { + const headers = ["Date", "Description", "Amount"]; + const settings = { columnFormat: [], chosenColumns: {} }; + const fingerprint = ConfigMatcher.generateFingerprint(headers); + + // First save + ConfigMatcher.saveConfiguration(headers, "test.csv", settings); + + const firstSave = JSON.parse(mockStore.knownConfigurations); + expect(firstSave.configs[fingerprint].useCount).toBe(1); + + // Second save (update) - localStorage already has data from first save + ConfigMatcher.saveConfiguration(headers, "test.csv", settings); + + const secondSave = JSON.parse(mockStore.knownConfigurations); + expect(secondSave.configs[fingerprint].useCount).toBe(2); + }); + }); + + describe("getAllConfigurations", () => { + test("should return empty object when no configs saved", () => { + const configs = ConfigMatcher.getAllConfigurations(); + expect(configs).toEqual({}); + }); + + test("should return saved configurations", () => { + const storedData = { + configs: { + fp_123: { id: "fp_123", name: "Test Config" }, + }, + }; + mockStore.knownConfigurations = JSON.stringify(storedData); + + const configs = ConfigMatcher.getAllConfigurations(); + + expect(configs).toEqual(storedData.configs); + }); + }); + + describe("getConfiguration", () => { + test("should return null when config not found", () => { + const config = ConfigMatcher.getConfiguration("fp_nonexistent"); + expect(config).toBeNull(); + }); + + test("should return config when found", () => { + const storedData = { + configs: { + fp_123: { id: "fp_123", name: "Test Config" }, + }, + }; + mockStore.knownConfigurations = JSON.stringify(storedData); + + const config = ConfigMatcher.getConfiguration("fp_123"); + + expect(config).toEqual({ id: "fp_123", name: "Test Config" }); + }); + }); + + describe("updateConfiguration", () => { + test("should update existing config", () => { + const storedData = { + configs: { + fp_123: { id: "fp_123", name: "Old Name", useCount: 5 }, + }, + }; + mockStore.knownConfigurations = JSON.stringify(storedData); + + const result = ConfigMatcher.updateConfiguration("fp_123", { + name: "New Name", + }); + + expect(result).toBe(true); + const savedData = JSON.parse(mockStore.knownConfigurations); + expect(savedData.configs.fp_123.name).toBe("New Name"); + expect(savedData.configs.fp_123.useCount).toBe(5); // Unchanged + }); + + test("should return false for non-existent config", () => { + const result = ConfigMatcher.updateConfiguration("fp_nonexistent", { + name: "Test", + }); + + expect(result).toBe(false); + }); + }); + + describe("deleteConfiguration", () => { + test("should delete existing config", () => { + const storedData = { + configs: { + fp_123: { id: "fp_123", name: "Test" }, + fp_456: { id: "fp_456", name: "Another" }, + }, + }; + mockStore.knownConfigurations = JSON.stringify(storedData); + + const result = ConfigMatcher.deleteConfiguration("fp_123"); + + expect(result).toBe(true); + const savedData = JSON.parse(mockStore.knownConfigurations); + expect(savedData.configs.fp_123).toBeUndefined(); + expect(savedData.configs.fp_456).toBeDefined(); + }); + + test("should return false for non-existent config", () => { + const result = ConfigMatcher.deleteConfiguration("fp_nonexistent"); + expect(result).toBe(false); + }); + }); + + describe("renameConfiguration", () => { + test("should rename existing config", () => { + const storedData = { + configs: { + fp_123: { id: "fp_123", name: "Old Name" }, + }, + }; + mockStore.knownConfigurations = JSON.stringify(storedData); + + const result = ConfigMatcher.renameConfiguration("fp_123", "New Name"); + + expect(result).toBe(true); + const savedData = JSON.parse(mockStore.knownConfigurations); + expect(savedData.configs.fp_123.name).toBe("New Name"); + }); + }); + + describe("incrementUsageCount", () => { + test("should increment usage count for existing config", () => { + const storedData = { + configs: { + fp_123: { id: "fp_123", name: "Test", useCount: 5 }, + }, + }; + mockStore.knownConfigurations = JSON.stringify(storedData); + + const result = ConfigMatcher.incrementUsageCount("fp_123"); + + expect(result).toBe(true); + const savedData = JSON.parse(mockStore.knownConfigurations); + expect(savedData.configs.fp_123.useCount).toBe(6); + expect(savedData.configs.fp_123.lastUsed).toBeDefined(); + }); + + test("should return false for non-existent config", () => { + const result = ConfigMatcher.incrementUsageCount("fp_nonexistent"); + + expect(result).toBe(false); + }); + + test("should initialize useCount if not present", () => { + const storedData = { + configs: { + fp_123: { id: "fp_123", name: "Test" }, + }, + }; + mockStore.knownConfigurations = JSON.stringify(storedData); + + ConfigMatcher.incrementUsageCount("fp_123"); + + const savedData = JSON.parse(mockStore.knownConfigurations); + expect(savedData.configs.fp_123.useCount).toBe(1); + }); + }); + + // ============================================ + // Error Handling Tests + // ============================================ + + describe("error handling", () => { + test("should handle invalid JSON in localStorage gracefully", () => { + mockStore.knownConfigurations = "invalid json {{{"; + global.console.warn = jest.fn(); + + const configs = ConfigMatcher.getAllConfigurations(); + + expect(configs).toEqual({}); + expect(global.console.warn).toHaveBeenCalledWith( + "ConfigMatcher: Failed to parse stored configs", + expect.any(Error), + ); + + delete global.console.warn; + }); + + test("should handle localStorage without configs property", () => { + mockStore.knownConfigurations = JSON.stringify({ notConfigs: {} }); + + const configs = ConfigMatcher.getAllConfigurations(); + + expect(configs).toEqual({}); + }); + + test("should handle localStorage setItem failure", () => { + global.console.warn = jest.fn(); + + // Make setItem throw an error (simulating quota exceeded) + global.localStorage.setItem.mockImplementation(() => { + throw new Error("QuotaExceededError"); + }); + + const headers = ["Date", "Amount"]; + const result = ConfigMatcher.saveConfiguration(headers, "test.csv", { + columnFormat: [], + chosenColumns: {}, + }); + + expect(result).toBeNull(); + expect(global.console.warn).toHaveBeenCalledWith( + "ConfigMatcher: Failed to save configs", + expect.any(Error), + ); + + delete global.console.warn; + }); + + test("should handle PapaParse errors gracefully", () => { + global.console.error = jest.fn(); + + // Make Papa.parse throw an error + global.Papa.parse.mockImplementation(() => { + throw new Error("Parse error"); + }); + + const headers = ConfigMatcher.extractHeadersFromContent( + "Date,Amount\n2024-01-01,-50", + ); + + expect(headers).toEqual([]); + expect(global.console.error).toHaveBeenCalledWith( + "Error parsing headers:", + expect.any(Error), + ); + + delete global.console.error; + }); + }); + + // ============================================ + // Matching Algorithm Tests + // ============================================ + + describe("findMatchingConfig", () => { + test("should find exact match by fingerprint", () => { + const headers = ["Date", "Description", "Amount"]; + const fingerprint = ConfigMatcher.generateFingerprint(headers); + + const storedData = { + configs: { + [fingerprint]: { + id: fingerprint, + name: "Chase", + chosenColumns: { Date: "Date", Payee: "Description" }, + }, + }, + }; + mockStore.knownConfigurations = JSON.stringify(storedData); + + const match = ConfigMatcher.findMatchingConfig(headers, "test.csv"); + + expect(match).not.toBeNull(); + expect(match.matchType).toBe("exact"); + expect(match.confidence).toBe(100); + expect(match.configId).toBe(fingerprint); + }); + + test("should return null when no configs saved", () => { + const match = ConfigMatcher.findMatchingConfig( + ["Date", "Amount"], + "test.csv", + ); + + expect(match).toBeNull(); + }); + + test("should find partial match when headers overlap", () => { + const storedData = { + configs: { + fp_123: { + id: "fp_123", + name: "Bank Export", + chosenColumns: { + Date: "Date", + Payee: "Description", + Amount: "Amount", + }, + originalHeaders: ["Date", "Description", "Amount", "Balance"], + }, + }, + }; + mockStore.knownConfigurations = JSON.stringify(storedData); + + // New file has similar but not identical headers + const newHeaders = ["Date", "Description", "Amount"]; + const match = ConfigMatcher.findMatchingConfig(newHeaders, "test.csv"); + + expect(match).not.toBeNull(); + expect(match.matchType).toBe("partial"); + expect(match.confidence).toBeGreaterThanOrEqual(70); + }); + + test("should find filename pattern match", () => { + const storedData = { + configs: { + fp_123: { + id: "fp_123", + name: "Chase", + filenamePatterns: ["chase", "statement"], + chosenColumns: { Date: "TransDate" }, + }, + }, + }; + mockStore.knownConfigurations = JSON.stringify(storedData); + + // Different headers but matching filename pattern + const match = ConfigMatcher.findMatchingConfig( + ["TransDate", "Memo", "Value"], + "chase_statement_2025.csv", + ); + + expect(match).not.toBeNull(); + expect(match.matchType).toBe("filename_only"); + expect(match.confidence).toBe(60); + }); + + test("should prioritize exact match over partial", () => { + const exactHeaders = ["Date", "Description", "Amount"]; + const exactFingerprint = ConfigMatcher.generateFingerprint(exactHeaders); + + const storedData = { + configs: { + [exactFingerprint]: { + id: exactFingerprint, + name: "Exact Match", + originalHeaders: exactHeaders, + }, + fp_other: { + id: "fp_other", + name: "Partial Match", + originalHeaders: [ + "Date", + "Description", + "Amount", + "Balance", + "Extra", + ], + }, + }, + }; + mockStore.knownConfigurations = JSON.stringify(storedData); + + const match = ConfigMatcher.findMatchingConfig(exactHeaders, "test.csv"); + + expect(match.matchType).toBe("exact"); + expect(match.configId).toBe(exactFingerprint); + }); + }); + + describe("findMatchingConfigWithStartRow", () => { + test("should find match when headers on row 1", () => { + const headers = ["Date", "Description", "Amount"]; + const fingerprint = ConfigMatcher.generateFingerprint(headers); + + const storedData = { + configs: { + [fingerprint]: { + id: fingerprint, + name: "Test", + startAtRow: 1, + }, + }, + }; + mockStore.knownConfigurations = JSON.stringify(storedData); + + const content = "Date,Description,Amount\n2024-01-01,Test,-50"; + const match = ConfigMatcher.findMatchingConfigWithStartRow( + content, + "test.csv", + "auto", + ); + + expect(match).not.toBeNull(); + expect(match.detectedStartAtRow).toBe(1); + }); + + test("should find match when headers on row 3", () => { + const headers = ["Date", "Payee", "Amount"]; + const fingerprint = ConfigMatcher.generateFingerprint(headers); + + const storedData = { + configs: { + [fingerprint]: { + id: fingerprint, + name: "Metadata File", + startAtRow: 3, + }, + }, + }; + mockStore.knownConfigurations = JSON.stringify(storedData); + + // Read the actual test file + const content = fs.readFileSync( + path.join(__dirname, "../test_files/metadata_header_row3.csv"), + "utf8", + ); + + const match = ConfigMatcher.findMatchingConfigWithStartRow( + content, + "metadata_header_row3.csv", + "auto", + ); + + expect(match).not.toBeNull(); + expect(match.detectedStartAtRow).toBe(3); + }); + + test("should try multiple startAtRow values from saved configs", () => { + const row1Headers = ["A", "B", "C"]; + const row3Headers = ["Date", "Payee", "Amount"]; + const row3Fingerprint = ConfigMatcher.generateFingerprint(row3Headers); + + const storedData = { + configs: { + fp_row1: { + id: "fp_row1", + name: "Row 1 Config", + startAtRow: 1, + originalHeaders: row1Headers, + }, + [row3Fingerprint]: { + id: row3Fingerprint, + name: "Row 3 Config", + startAtRow: 3, + originalHeaders: row3Headers, + }, + }, + }; + mockStore.knownConfigurations = JSON.stringify(storedData); + + // File with headers on row 3 + const content = + "Metadata line 1\nMetadata line 2\nDate,Payee,Amount\n2024-01-01,Test,-50"; + + const match = ConfigMatcher.findMatchingConfigWithStartRow( + content, + "test.csv", + "auto", + ); + + expect(match).not.toBeNull(); + expect(match.configId).toBe(row3Fingerprint); + expect(match.detectedStartAtRow).toBe(3); + }); + }); + + // ============================================ + // Integration tests with real test files + // ============================================ + + describe("integration with test files", () => { + test("should match chase_statement files correctly", () => { + // Save config from first chase file + const chase2024Content = fs.readFileSync( + path.join(__dirname, "../test_files/chase_statement_2024.csv"), + "utf8", + ); + const chase2024Headers = + ConfigMatcher.extractHeadersFromContent(chase2024Content); + + const settings = { + columnFormat: ["Date", "Payee", "Memo", "Amount"], + chosenColumns: { + Date: "Date", + Payee: "Description", + Amount: "Amount", + }, + startAtRow: 1, + }; + + ConfigMatcher.saveConfiguration( + chase2024Headers, + "chase_statement_2024.csv", + settings, + "Chase Bank", + ); + + // Now try to match with chase_statement_2025.csv + const chase2025Content = fs.readFileSync( + path.join(__dirname, "../test_files/chase_statement_2025.csv"), + "utf8", + ); + + const match = ConfigMatcher.findMatchingConfigWithStartRow( + chase2025Content, + "chase_statement_2025.csv", + "auto", + ); + + expect(match).not.toBeNull(); + expect(match.matchType).toBe("exact"); + expect(match.confidence).toBe(100); + expect(match.config.name).toBe("Chase Bank"); + }); + + test("should not match chase with wells_fargo (different headers)", () => { + // Save chase config + const chaseHeaders = ["Date", "Description", "Amount", "Balance"]; + const fingerprint = ConfigMatcher.generateFingerprint(chaseHeaders); + + const storedData = { + configs: { + [fingerprint]: { + id: fingerprint, + name: "Chase", + originalHeaders: chaseHeaders, + filenamePatterns: ["chase", "statement"], + }, + }, + }; + mockStore.knownConfigurations = JSON.stringify(storedData); + + // Try to match wells fargo file + const wellsFargoContent = fs.readFileSync( + path.join(__dirname, "../test_files/wells_fargo_checking.csv"), + "utf8", + ); + + const match = ConfigMatcher.findMatchingConfigWithStartRow( + wellsFargoContent, + "wells_fargo_checking.csv", + "auto", + ); + + // Should not match (different headers, different filename pattern) + expect(match).toBeNull(); + }); + + test("should not match kontoutdrag English with Swedish (same pattern, different headers)", () => { + // Save English kontoutdrag config + const englishHeaders = [ + "Booking date", + "Value date", + "Voucher number", + "Text", + "Amount", + "Balance", + ]; + const fingerprint = ConfigMatcher.generateFingerprint(englishHeaders); + + const storedData = { + configs: { + [fingerprint]: { + id: fingerprint, + name: "Kontoutdrag English", + originalHeaders: englishHeaders, + filenamePatterns: ["kontoutdrag"], + }, + }, + }; + mockStore.knownConfigurations = JSON.stringify(storedData); + + // Read Swedish kontoutdrag + const swedishContent = fs.readFileSync( + path.join(__dirname, "../test_files/kontoutdrag 20260103-0639.csv"), + "utf8", + ); + + const match = ConfigMatcher.findMatchingConfigWithStartRow( + swedishContent, + "kontoutdrag 20260103-0639.csv", + "auto", + ); + + // Should match on filename pattern only (headers are different) + // The filename match should be returned since headers don't match exactly + if (match) { + expect(match.matchType).toBe("filename_only"); + expect(match.confidence).toBe(60); + } + // Or it could be null if the partial header match doesn't meet threshold + }); + }); +}); diff --git a/tests/data_object.test.js b/tests/data_object.test.js index 05419b8..80e16f9 100644 --- a/tests/data_object.test.js +++ b/tests/data_object.test.js @@ -1,12 +1,12 @@ // Mock PapaParse global.Papa = { - parse: jest.fn() + parse: jest.fn(), }; // Import DataObject after mocking Papa -require('../src/data_object.js'); +require("../src/data_object.js"); -describe('DataObject', () => { +describe("DataObject", () => { let dataObject; beforeEach(() => { @@ -14,432 +14,1178 @@ describe('DataObject', () => { jest.clearAllMocks(); }); - describe('CSV Parsing', () => { - test('should parse CSV with default parameters', () => { - const csvData = 'header1,header2\nvalue1,value2'; + describe("CSV Parsing", () => { + test("should parse CSV with default parameters", () => { + const csvData = "header1,header2\nvalue1,value2"; const mockParsedData = { - data: [{ header1: 'value1', header2: 'value2' }], - meta: { fields: ['header1', 'header2'] } + data: [{ header1: "value1", header2: "value2" }], + meta: { fields: ["header1", "header2"] }, }; - + global.Papa.parse.mockReturnValue(mockParsedData); - - const result = dataObject.parseCsv(csvData, 'UTF-8'); - - expect(global.Papa.parse).toHaveBeenCalledWith(csvData, expect.objectContaining({ - header: true, - skipEmptyLines: true - })); + + const result = dataObject.parseCsv(csvData, "UTF-8"); + + expect(global.Papa.parse).toHaveBeenCalledWith( + csvData, + expect.objectContaining({ + header: true, + skipEmptyLines: true, + }), + ); expect(result).toEqual(mockParsedData); expect(dataObject.base_json).toEqual(mockParsedData); }); - test('should parse CSV starting at specific row', () => { - const csvData = 'skip1\nskip2\nheader1,header2\nvalue1,value2'; - + test("should parse CSV starting at specific row", () => { + const csvData = "skip1\nskip2\nheader1,header2\nvalue1,value2"; + global.Papa.parse.mockImplementation((data, config) => { const transformed = config.beforeFirstChunk(data); - expect(transformed).toBe('header1,header2\nvalue1,value2'); + expect(transformed).toBe("header1,header2\nvalue1,value2"); return { data: [], meta: { fields: [] } }; }); - - dataObject.parseCsv(csvData, 'UTF-8', 3); - + + dataObject.parseCsv(csvData, "UTF-8", 3); + expect(global.Papa.parse).toHaveBeenCalled(); }); - test('should duplicate first data row when extraRow is true', () => { - const csvData = 'header1,header2\nvalue1,value2\nvalue3,value4'; - + test("should duplicate first data row when extraRow is true", () => { + const csvData = "header1,header2\nvalue1,value2\nvalue3,value4"; + global.Papa.parse.mockImplementation((data, config) => { const transformed = config.beforeFirstChunk(data); - const lines = transformed.split('\n'); - expect(lines[0]).toBe('header1,header2'); - expect(lines[1]).toBe('header1,header2'); // Duplicated - expect(lines[2]).toBe('value1,value2'); + const lines = transformed.split("\n"); + expect(lines[0]).toBe("header1,header2"); + expect(lines[1]).toBe("header1,header2"); // Duplicated + expect(lines[2]).toBe("value1,value2"); return { data: [], meta: { fields: [] } }; }); - - dataObject.parseCsv(csvData, 'UTF-8', 1, true); - + + dataObject.parseCsv(csvData, "UTF-8", 1, true); + expect(global.Papa.parse).toHaveBeenCalled(); }); - test('should use custom delimiter when specified', () => { - const csvData = 'header1;header2\nvalue1;value2'; - + test("should use custom delimiter when specified", () => { + const csvData = "header1;header2\nvalue1;value2"; + global.Papa.parse.mockReturnValue({ data: [], meta: { fields: [] } }); - - dataObject.parseCsv(csvData, 'UTF-8', 1, false, ';'); - - expect(global.Papa.parse).toHaveBeenCalledWith(csvData, expect.objectContaining({ - delimiter: ';' - })); + + dataObject.parseCsv(csvData, "UTF-8", 1, false, ";"); + + expect(global.Papa.parse).toHaveBeenCalledWith( + csvData, + expect.objectContaining({ + delimiter: ";", + }), + ); }); - test('should handle empty headers', () => { - const csvData = ',header2\nvalue1,value2'; - + test("should handle empty headers", () => { + const csvData = ",header2\nvalue1,value2"; + global.Papa.parse.mockImplementation((data, config) => { - const transformedHeader = config.transformHeader(' '); - expect(transformedHeader).toBe('Unnamed column'); + const transformedHeader = config.transformHeader(" "); + expect(transformedHeader).toBe("Unnamed column"); return { data: [], meta: { fields: [] } }; }); - - dataObject.parseCsv(csvData, 'UTF-8'); - + + dataObject.parseCsv(csvData, "UTF-8"); + expect(global.Papa.parse).toHaveBeenCalled(); }); - test('should handle duplicate headers', () => { - const csvData = 'header1,header1,header1\nvalue1,value2,value3'; - + test("should handle duplicate headers", () => { + const csvData = "header1,header1,header1\nvalue1,value2,value3"; + global.Papa.parse.mockImplementation((data, config) => { // Simulate header transformation const headers = []; - headers.push(config.transformHeader('header1')); // 'header1' - headers.push(config.transformHeader('header1')); // 'header1 (1)' - headers.push(config.transformHeader('header1')); // 'header1 (2)' - - expect(headers).toEqual(['header1', 'header1 (1)', 'header1 (2)']); + headers.push(config.transformHeader("header1")); // 'header1' + headers.push(config.transformHeader("header1")); // 'header1 (1)' + headers.push(config.transformHeader("header1")); // 'header1 (2)' + + expect(headers).toEqual(["header1", "header1 (1)", "header1 (2)"]); return { data: [], meta: { fields: headers } }; }); - - dataObject.parseCsv(csvData, 'UTF-8'); - + + dataObject.parseCsv(csvData, "UTF-8"); + expect(global.Papa.parse).toHaveBeenCalled(); }); }); - describe('Data Access Methods', () => { - test('fields() should return parsed fields', () => { - const mockFields = ['field1', 'field2', 'field3']; + describe("Data Access Methods", () => { + test("fields() should return parsed fields", () => { + const mockFields = ["field1", "field2", "field3"]; dataObject.base_json = { meta: { fields: mockFields } }; - + expect(dataObject.fields()).toEqual(mockFields); }); - test('rows() should return parsed data', () => { + test("rows() should return parsed data", () => { const mockRows = [ - { col1: 'val1', col2: 'val2' }, - { col1: 'val3', col2: 'val4' } + { col1: "val1", col2: "val2" }, + { col1: "val3", col2: "val4" }, ]; dataObject.base_json = { data: mockRows }; - + expect(dataObject.rows()).toEqual(mockRows); }); }); - describe('Data Conversion - converted_json()', () => { + describe("Data Conversion - converted_json()", () => { beforeEach(() => { dataObject.base_json = { data: [ - { 'Date': '2024-01-01', 'Description': 'Purchase', 'Amount': '-50.00', 'Notes': 'Groceries' }, - { 'Date': '2024-01-02', 'Description': 'Salary', 'Amount': '1000.00', 'Notes': 'Monthly pay' }, - { 'Date': '2024-01-03', 'Description': 'Refund', 'Amount': '25.50', 'Notes': 'Return' } + { + Date: "2024-01-01", + Description: "Purchase", + Amount: "-50.00", + Notes: "Groceries", + }, + { + Date: "2024-01-02", + Description: "Salary", + Amount: "1000.00", + Notes: "Monthly pay", + }, + { + Date: "2024-01-03", + Description: "Refund", + Amount: "25.50", + Notes: "Return", + }, ], - meta: { fields: ['Date', 'Description', 'Amount', 'Notes'] } + meta: { fields: ["Date", "Description", "Amount", "Notes"] }, }; }); - test('should convert to old YNAB format with separate Inflow/Outflow', () => { - const ynab_cols = ['Date', 'Payee', 'Memo', 'Outflow', 'Inflow']; + test("should convert to old YNAB format with separate Inflow/Outflow", () => { + const ynab_cols = ["Date", "Payee", "Memo", "Outflow", "Inflow"]; const lookup = { - 'Date': 'Date', - 'Payee': 'Description', - 'Memo': 'Notes', - 'Outflow': 'Amount', - 'Inflow': 'Amount' + Date: "Date", + Payee: "Description", + Memo: "Notes", + Outflow: "Amount", + Inflow: "Amount", }; - + const result = dataObject.converted_json(null, ynab_cols, lookup); - + expect(result).toHaveLength(3); expect(result[0]).toEqual({ - 'Date': '2024-01-01', - 'Payee': 'Purchase', - 'Memo': 'Groceries', - 'Outflow': '50.00', - 'Inflow': '' + Date: "2024-01-01", + Payee: "Purchase", + Memo: "Groceries", + Outflow: "50.00", + Inflow: "", }); expect(result[1]).toEqual({ - 'Date': '2024-01-02', - 'Payee': 'Salary', - 'Memo': 'Monthly pay', - 'Outflow': '', - 'Inflow': '1000.00' + Date: "2024-01-02", + Payee: "Salary", + Memo: "Monthly pay", + Outflow: "", + Inflow: "1000.00", }); }); - test('should convert to new YNAB format with combined Amount column', () => { - const ynab_cols = ['Date', 'Payee', 'Memo', 'Amount']; + test("should convert to new YNAB format with combined Amount column", () => { + const ynab_cols = ["Date", "Payee", "Memo", "Amount"]; const lookup = { - 'Date': 'Date', - 'Payee': 'Description', - 'Memo': 'Notes', - 'Amount': 'Amount' + Date: "Date", + Payee: "Description", + Memo: "Notes", + Amount: "Amount", }; - + const result = dataObject.converted_json(null, ynab_cols, lookup); - + expect(result).toHaveLength(3); expect(result[0]).toEqual({ - 'Date': '2024-01-01', - 'Payee': 'Purchase', - 'Memo': 'Groceries', - 'Amount': '-50.00' + Date: "2024-01-01", + Payee: "Purchase", + Memo: "Groceries", + Amount: "-50.00", }); expect(result[1]).toEqual({ - 'Date': '2024-01-02', - 'Payee': 'Salary', - 'Memo': 'Monthly pay', - 'Amount': '1000.00' + Date: "2024-01-02", + Payee: "Salary", + Memo: "Monthly pay", + Amount: "1000.00", }); }); - test('should handle inverted outflow logic', () => { - const ynab_cols = ['Date', 'Payee', 'Memo', 'Outflow', 'Inflow']; + test("should handle inverted outflow logic", () => { + const ynab_cols = ["Date", "Payee", "Memo", "Outflow", "Inflow"]; const lookup = { - 'Date': 'Date', - 'Payee': 'Description', - 'Memo': 'Notes', - 'Outflow': 'Amount', - 'Inflow': 'Amount' + Date: "Date", + Payee: "Description", + Memo: "Notes", + Outflow: "Amount", + Inflow: "Amount", }; - + const result = dataObject.converted_json(null, ynab_cols, lookup, true); - + expect(result[0]).toEqual({ - 'Date': '2024-01-01', - 'Payee': 'Purchase', - 'Memo': 'Groceries', - 'Outflow': '', - 'Inflow': '50.00' + Date: "2024-01-01", + Payee: "Purchase", + Memo: "Groceries", + Outflow: "", + Inflow: "50.00", }); expect(result[1]).toEqual({ - 'Date': '2024-01-02', - 'Payee': 'Salary', - 'Memo': 'Monthly pay', - 'Outflow': '1000.00', - 'Inflow': '' + Date: "2024-01-02", + Payee: "Salary", + Memo: "Monthly pay", + Outflow: "1000.00", + Inflow: "", }); }); - test('should respect limit parameter for preview', () => { - const ynab_cols = ['Date', 'Payee', 'Memo', 'Amount']; + test("should invert Amount sign when inverted_amount is true", () => { + const ynab_cols = ["Date", "Payee", "Memo", "Amount"]; const lookup = { - 'Date': 'Date', - 'Payee': 'Description', - 'Memo': 'Notes', - 'Amount': 'Amount' + Date: "Date", + Payee: "Description", + Memo: "Notes", + Amount: "Amount", }; - + + // inverted_outflow = false, inverted_amount = true + const result = dataObject.converted_json( + null, + ynab_cols, + lookup, + false, + true, + ); + + // Negative becomes positive + expect(result[0]).toEqual({ + Date: "2024-01-01", + Payee: "Purchase", + Memo: "Groceries", + Amount: "50.00", // was "-50.00" + }); + // Positive becomes negative + expect(result[1]).toEqual({ + Date: "2024-01-02", + Payee: "Salary", + Memo: "Monthly pay", + Amount: "-1000.00", // was "1000.00" + }); + expect(result[2]).toEqual({ + Date: "2024-01-03", + Payee: "Refund", + Memo: "Return", + Amount: "-25.50", // was "25.50" + }); + }); + + test("should not invert Amount sign when inverted_amount is false", () => { + const ynab_cols = ["Date", "Payee", "Memo", "Amount"]; + const lookup = { + Date: "Date", + Payee: "Description", + Memo: "Notes", + Amount: "Amount", + }; + + // inverted_outflow = false, inverted_amount = false (default) + const result = dataObject.converted_json(null, ynab_cols, lookup, false); + + // Values should remain unchanged + expect(result[0].Amount).toBe("-50.00"); + expect(result[1].Amount).toBe("1000.00"); + expect(result[2].Amount).toBe("25.50"); + }); + + test("should respect limit parameter for preview", () => { + const ynab_cols = ["Date", "Payee", "Memo", "Amount"]; + const lookup = { + Date: "Date", + Payee: "Description", + Memo: "Notes", + Amount: "Amount", + }; + const result = dataObject.converted_json(2, ynab_cols, lookup); - + expect(result).toHaveLength(2); - expect(result[0].Payee).toBe('Purchase'); - expect(result[1].Payee).toBe('Salary'); + expect(result[0].Payee).toBe("Purchase"); + expect(result[1].Payee).toBe("Salary"); }); - test('should handle missing fields gracefully', () => { - const ynab_cols = ['Date', 'Payee', 'Memo', 'Category']; + test("should handle missing fields gracefully", () => { + const ynab_cols = ["Date", "Payee", "Memo", "Category"]; const lookup = { - 'Date': 'Date', - 'Payee': 'Description', - 'Memo': 'Notes', - 'Category': 'NonExistentField' + Date: "Date", + Payee: "Description", + Memo: "Notes", + Category: "NonExistentField", }; - + const result = dataObject.converted_json(1, ynab_cols, lookup); - + expect(result[0]).toEqual({ - 'Date': '2024-01-01', - 'Payee': 'Purchase', - 'Memo': 'Groceries' + Date: "2024-01-01", + Payee: "Purchase", + Memo: "Groceries", // Category is undefined, so not included }); }); - test('should handle separate Outflow/Inflow columns correctly', () => { + test("should handle separate Outflow/Inflow columns correctly", () => { dataObject.base_json = { data: [ - { 'Date': '2024-01-01', 'Payee': 'Store', 'Out': '50.00', 'In': '' }, - { 'Date': '2024-01-02', 'Payee': 'Work', 'Out': '', 'In': '1000.00' } + { Date: "2024-01-01", Payee: "Store", Out: "50.00", In: "" }, + { Date: "2024-01-02", Payee: "Work", Out: "", In: "1000.00" }, ], - meta: { fields: ['Date', 'Payee', 'Out', 'In'] } + meta: { fields: ["Date", "Payee", "Out", "In"] }, }; - - const ynab_cols = ['Date', 'Payee', 'Outflow', 'Inflow']; + + const ynab_cols = ["Date", "Payee", "Outflow", "Inflow"]; const lookup = { - 'Date': 'Date', - 'Payee': 'Payee', - 'Outflow': 'Out', - 'Inflow': 'In' + Date: "Date", + Payee: "Payee", + Outflow: "Out", + Inflow: "In", }; - + const result = dataObject.converted_json(null, ynab_cols, lookup); - + expect(result[0]).toEqual({ - 'Date': '2024-01-01', - 'Payee': 'Store', - 'Outflow': '50.00' + Date: "2024-01-01", + Payee: "Store", + Outflow: "50.00", // Inflow is empty string, so not included in result }); expect(result[1]).toEqual({ - 'Date': '2024-01-02', - 'Payee': 'Work', - 'Inflow': '1000.00' + Date: "2024-01-02", + Payee: "Work", + Inflow: "1000.00", // Outflow is empty string, so not included in result }); }); - test('should return null if base_json is null', () => { + test("should return null if base_json is null", () => { dataObject.base_json = null; - + const result = dataObject.converted_json(null, [], {}); - + expect(result).toBeNull(); }); }); - describe('CSV Export - converted_csv()', () => { + describe("CSV Export - converted_csv()", () => { beforeEach(() => { dataObject.base_json = { data: [ - { 'Date': '2024-01-01', 'Description': 'Purchase', 'Amount': '-50.00', 'Notes': 'Groceries' }, - { 'Date': '2024-01-02', 'Description': 'Salary', 'Amount': '1000.00', 'Notes': 'Monthly pay' } + { + Date: "2024-01-01", + Description: "Purchase", + Amount: "-50.00", + Notes: "Groceries", + }, + { + Date: "2024-01-02", + Description: "Salary", + Amount: "1000.00", + Notes: "Monthly pay", + }, ], - meta: { fields: ['Date', 'Description', 'Amount', 'Notes'] } + meta: { fields: ["Date", "Description", "Amount", "Notes"] }, }; }); - test('should export to CSV format with old YNAB columns', () => { - const ynab_cols = ['Date', 'Payee', 'Memo', 'Outflow', 'Inflow']; + test("should export to CSV format with old YNAB columns", () => { + const ynab_cols = ["Date", "Payee", "Memo", "Outflow", "Inflow"]; const lookup = { - 'Date': 'Date', - 'Payee': 'Description', - 'Memo': 'Notes', - 'Outflow': 'Amount', - 'Inflow': 'Amount' + Date: "Date", + Payee: "Description", + Memo: "Notes", + Outflow: "Amount", + Inflow: "Amount", }; - + const result = dataObject.converted_csv(null, ynab_cols, lookup); - - const lines = result.trim().split('\n'); + + const lines = result.trim().split("\n"); expect(lines).toHaveLength(3); // header + 2 data rows expect(lines[0]).toBe('"Date","Payee","Memo","Outflow","Inflow"'); expect(lines[1]).toBe('"2024-01-01","Purchase","Groceries","50.00",""'); expect(lines[2]).toBe('"2024-01-02","Salary","Monthly pay","","1000.00"'); }); - test('should export to CSV format with new YNAB columns', () => { - const ynab_cols = ['Date', 'Payee', 'Memo', 'Amount']; + test("should export to CSV format with new YNAB columns", () => { + const ynab_cols = ["Date", "Payee", "Memo", "Amount"]; const lookup = { - 'Date': 'Date', - 'Payee': 'Description', - 'Memo': 'Notes', - 'Amount': 'Amount' + Date: "Date", + Payee: "Description", + Memo: "Notes", + Amount: "Amount", }; - + const result = dataObject.converted_csv(null, ynab_cols, lookup); - - const lines = result.trim().split('\n'); + + const lines = result.trim().split("\n"); expect(lines).toHaveLength(3); expect(lines[0]).toBe('"Date","Payee","Memo","Amount"'); expect(lines[1]).toBe('"2024-01-01","Purchase","Groceries","-50.00"'); expect(lines[2]).toBe('"2024-01-02","Salary","Monthly pay","1000.00"'); }); - test('should properly escape quotes in values', () => { + test("should properly escape quotes in values", () => { dataObject.base_json = { data: [ - { 'Date': '2024-01-01', 'Description': 'Store "ABC"', 'Amount': '-50.00', 'Notes': 'Bought "stuff"' } + { + Date: "2024-01-01", + Description: 'Store "ABC"', + Amount: "-50.00", + Notes: 'Bought "stuff"', + }, ], - meta: { fields: ['Date', 'Description', 'Amount', 'Notes'] } + meta: { fields: ["Date", "Description", "Amount", "Notes"] }, }; - - const ynab_cols = ['Date', 'Payee', 'Memo', 'Amount']; + + const ynab_cols = ["Date", "Payee", "Memo", "Amount"]; const lookup = { - 'Date': 'Date', - 'Payee': 'Description', - 'Memo': 'Notes', - 'Amount': 'Amount' + Date: "Date", + Payee: "Description", + Memo: "Notes", + Amount: "Amount", }; - + const result = dataObject.converted_csv(null, ynab_cols, lookup); - - const lines = result.trim().split('\n'); - expect(lines[1]).toBe('"2024-01-01","Store ""ABC""","Bought ""stuff""","-50.00"'); + + const lines = result.trim().split("\n"); + expect(lines[1]).toBe( + '"2024-01-01","Store ""ABC""","Bought ""stuff""","-50.00"', + ); }); - test('should trim values', () => { + test("should trim values", () => { dataObject.base_json = { data: [ - { 'Date': ' 2024-01-01 ', 'Description': ' Purchase ', 'Amount': ' -50.00 ', 'Notes': ' Groceries ' } + { + Date: " 2024-01-01 ", + Description: " Purchase ", + Amount: " -50.00 ", + Notes: " Groceries ", + }, ], - meta: { fields: ['Date', 'Description', 'Amount', 'Notes'] } + meta: { fields: ["Date", "Description", "Amount", "Notes"] }, }; - - const ynab_cols = ['Date', 'Payee', 'Memo', 'Amount']; + + const ynab_cols = ["Date", "Payee", "Memo", "Amount"]; const lookup = { - 'Date': 'Date', - 'Payee': 'Description', - 'Memo': 'Notes', - 'Amount': 'Amount' + Date: "Date", + Payee: "Description", + Memo: "Notes", + Amount: "Amount", }; - + const result = dataObject.converted_csv(null, ynab_cols, lookup); - - const lines = result.trim().split('\n'); + + const lines = result.trim().split("\n"); expect(lines[1]).toBe('"2024-01-01","Purchase","Groceries","-50.00"'); }); - test('should handle empty values', () => { + test("should handle empty values", () => { dataObject.base_json = { data: [ - { 'Date': '2024-01-01', 'Description': '', 'Amount': '-50.00', 'Notes': null } + { + Date: "2024-01-01", + Description: "", + Amount: "-50.00", + Notes: null, + }, ], - meta: { fields: ['Date', 'Description', 'Amount', 'Notes'] } + meta: { fields: ["Date", "Description", "Amount", "Notes"] }, }; - - const ynab_cols = ['Date', 'Payee', 'Memo', 'Amount']; + + const ynab_cols = ["Date", "Payee", "Memo", "Amount"]; const lookup = { - 'Date': 'Date', - 'Payee': 'Description', - 'Memo': 'Notes', - 'Amount': 'Amount' + Date: "Date", + Payee: "Description", + Memo: "Notes", + Amount: "Amount", }; - + const result = dataObject.converted_csv(null, ynab_cols, lookup); - - const lines = result.trim().split('\n'); + + const lines = result.trim().split("\n"); expect(lines[1]).toBe('"2024-01-01","","","-50.00"'); }); - test('should respect limit parameter', () => { + test("should respect limit parameter", () => { dataObject.base_json = { data: [ - { 'Date': '2024-01-01', 'Description': 'Purchase 1', 'Amount': '-50.00', 'Notes': 'Note 1' }, - { 'Date': '2024-01-02', 'Description': 'Purchase 2', 'Amount': '-60.00', 'Notes': 'Note 2' }, - { 'Date': '2024-01-03', 'Description': 'Purchase 3', 'Amount': '-70.00', 'Notes': 'Note 3' } + { + Date: "2024-01-01", + Description: "Purchase 1", + Amount: "-50.00", + Notes: "Note 1", + }, + { + Date: "2024-01-02", + Description: "Purchase 2", + Amount: "-60.00", + Notes: "Note 2", + }, + { + Date: "2024-01-03", + Description: "Purchase 3", + Amount: "-70.00", + Notes: "Note 3", + }, ], - meta: { fields: ['Date', 'Description', 'Amount', 'Notes'] } + meta: { fields: ["Date", "Description", "Amount", "Notes"] }, }; - - const ynab_cols = ['Date', 'Payee', 'Memo', 'Amount']; + + const ynab_cols = ["Date", "Payee", "Memo", "Amount"]; const lookup = { - 'Date': 'Date', - 'Payee': 'Description', - 'Memo': 'Notes', - 'Amount': 'Amount' + Date: "Date", + Payee: "Description", + Memo: "Notes", + Amount: "Amount", }; - + const result = dataObject.converted_csv(2, ynab_cols, lookup); - - const lines = result.trim().split('\n'); + + const lines = result.trim().split("\n"); expect(lines).toHaveLength(3); // header + 2 rows (limited) - expect(lines[1]).toContain('Purchase 1'); - expect(lines[2]).toContain('Purchase 2'); + expect(lines[1]).toContain("Purchase 1"); + expect(lines[2]).toContain("Purchase 2"); + }); + }); + + describe("Excel File Support", () => { + beforeEach(() => { + // Mock XLSX for Excel tests + global.XLSX = { + read: jest.fn(), + utils: { + sheet_to_csv: jest.fn(), + }, + }; + }); + + afterEach(() => { + delete global.XLSX; + }); + + describe("isExcelFile()", () => { + test("should detect Excel files by extension", () => { + expect(dataObject.isExcelFile("test.xlsx")).toBe(true); + expect(dataObject.isExcelFile("test.xls")).toBe(true); + expect(dataObject.isExcelFile("test.xlsm")).toBe(true); + expect(dataObject.isExcelFile("test.xlsb")).toBe(true); + expect(dataObject.isExcelFile("TEST.XLSX")).toBe(true); // case insensitive + }); + + test("should not detect non-Excel files", () => { + expect(dataObject.isExcelFile("test.csv")).toBe(false); + expect(dataObject.isExcelFile("test.txt")).toBe(false); + expect(dataObject.isExcelFile("test.pdf")).toBe(false); + expect(dataObject.isExcelFile("")).toBe(false); + expect(dataObject.isExcelFile(null)).toBe(false); + expect(dataObject.isExcelFile(undefined)).toBe(false); + }); + }); + + describe("parseExcel()", () => { + test("should parse Excel file with single worksheet", () => { + const mockWorkbook = { + SheetNames: ["Sheet1"], + Sheets: { + Sheet1: { + /* worksheet data */ + }, + }, + }; + const mockCsvContent = + "Date,Description,Amount\n2024-01-01,Purchase,-50.00"; + + global.XLSX.read.mockReturnValue(mockWorkbook); + global.XLSX.utils.sheet_to_csv.mockReturnValue(mockCsvContent); + + // Mock parseCsv to return expected data + const mockParsedData = { + data: [ + { Date: "2024-01-01", Description: "Purchase", Amount: "-50.00" }, + ], + meta: { fields: ["Date", "Description", "Amount"] }, + }; + dataObject.parseCsv = jest.fn().mockReturnValue(mockParsedData); + + const result = dataObject.parseExcel( + "binary_data", + "test.xlsx", + "UTF-8", + ); + + expect(global.XLSX.read).toHaveBeenCalledWith("binary_data", { + type: "binary", + }); + expect(global.XLSX.utils.sheet_to_csv).toHaveBeenCalledWith( + mockWorkbook.Sheets["Sheet1"], + ); + expect(dataObject.parseCsv).toHaveBeenCalledWith( + mockCsvContent, + "UTF-8", + 1, + false, + null, + ); + expect(result).toEqual(mockParsedData); + expect(dataObject.worksheetNames).toEqual(["Sheet1"]); + expect(dataObject.currentWorksheet).toBe("Sheet1"); + }); + + test("should parse Excel file with multiple worksheets", () => { + const mockWorkbook = { + SheetNames: ["Sheet1", "Sheet2", "Data"], + Sheets: { + Sheet1: { + /* worksheet 1 data */ + }, + Sheet2: { + /* worksheet 2 data */ + }, + Data: { + /* data worksheet */ + }, + }, + }; + const mockCsvContent = "Name,Value\nTest,123"; + + global.XLSX.read.mockReturnValue(mockWorkbook); + global.XLSX.utils.sheet_to_csv.mockReturnValue(mockCsvContent); + dataObject.parseCsv = jest + .fn() + .mockReturnValue({ data: [], meta: { fields: [] } }); + + // Test selecting a specific worksheet (index 2 = 'Data') + dataObject.parseExcel( + "binary_data", + "test.xlsx", + "UTF-8", + 1, + false, + null, + 2, + ); + + expect(global.XLSX.utils.sheet_to_csv).toHaveBeenCalledWith( + mockWorkbook.Sheets["Data"], + ); + expect(dataObject.worksheetNames).toEqual(["Sheet1", "Sheet2", "Data"]); + expect(dataObject.currentWorksheet).toBe("Data"); + }); + + test("should handle Excel parsing errors", () => { + const consoleSpy = jest.spyOn(console, "error").mockImplementation(); + + global.XLSX.read.mockImplementation(() => { + throw new Error("Invalid Excel file"); + }); + + expect(() => { + dataObject.parseExcel("invalid_data", "test.xlsx", "UTF-8"); + }).toThrow("Failed to parse Excel file: Invalid Excel file"); + + expect(consoleSpy).toHaveBeenCalledWith( + "Error parsing Excel file:", + expect.any(Error), + ); + consoleSpy.mockRestore(); + }); + + test("should handle empty workbook", () => { + const consoleSpy = jest.spyOn(console, "error").mockImplementation(); + + const mockWorkbook = { + SheetNames: [], + Sheets: {}, + }; + + global.XLSX.read.mockReturnValue(mockWorkbook); + + expect(() => { + dataObject.parseExcel("binary_data", "test.xlsx", "UTF-8"); + }).toThrow( + "Failed to parse Excel file: No worksheets found in Excel file", + ); + + expect(consoleSpy).toHaveBeenCalledWith( + "Error parsing Excel file:", + expect.any(Error), + ); + consoleSpy.mockRestore(); + }); + + test("should handle missing worksheet", () => { + const consoleSpy = jest.spyOn(console, "error").mockImplementation(); + + const mockWorkbook = { + SheetNames: ["Sheet1"], + Sheets: { + Sheet1: { + /* worksheet data */ + }, + }, + }; + + global.XLSX.read.mockReturnValue(mockWorkbook); + + expect(() => { + dataObject.parseExcel( + "binary_data", + "test.xlsx", + "UTF-8", + 1, + false, + null, + 5, + ); // invalid index + }).toThrow( + "Failed to parse Excel file: Worksheet index 5 is out of range. Available sheets: 1", + ); + + expect(consoleSpy).toHaveBeenCalledWith( + "Error parsing Excel file:", + expect.any(Error), + ); + consoleSpy.mockRestore(); + }); + + test("should pass through all parseCsv parameters", () => { + const mockWorkbook = { + SheetNames: ["Sheet1"], + Sheets: { + Sheet1: { + /* worksheet data */ + }, + }, + }; + const mockCsvContent = "Date,Amount\n2024-01-01,-50.00"; + + global.XLSX.read.mockReturnValue(mockWorkbook); + global.XLSX.utils.sheet_to_csv.mockReturnValue(mockCsvContent); + dataObject.parseCsv = jest + .fn() + .mockReturnValue({ data: [], meta: { fields: [] } }); + + dataObject.parseExcel( + "binary_data", + "test.xlsx", + "ISO-8859-1", + 3, + true, + ",", + 0, + ); + + expect(dataObject.parseCsv).toHaveBeenCalledWith( + mockCsvContent, + "ISO-8859-1", + 3, + true, + ",", + ); + }); + + test("should handle XLS files with ArrayBuffer data type", () => { + const mockWorkbook = { + SheetNames: ["Sheet1"], + Sheets: { + Sheet1: { + /* worksheet data */ + }, + }, + }; + const mockCsvContent = "Date,Amount\n2024-01-01,-50.00"; + + global.XLSX.read.mockReturnValue(mockWorkbook); + global.XLSX.utils.sheet_to_csv.mockReturnValue(mockCsvContent); + dataObject.parseCsv = jest + .fn() + .mockReturnValue({ data: [], meta: { fields: [] } }); + + // Test with ArrayBuffer for XLS file + const arrayBuffer = new ArrayBuffer(100); + dataObject.parseExcel(arrayBuffer, "test.xls", "UTF-8"); + + expect(global.XLSX.read).toHaveBeenCalledWith(expect.any(Uint8Array), { + type: "array", + }); + expect(dataObject.parseCsv).toHaveBeenCalledWith( + mockCsvContent, + "UTF-8", + 1, + false, + null, + ); + }); + + test("should handle XLSB files with ArrayBuffer data type", () => { + const mockWorkbook = { + SheetNames: ["Data"], + Sheets: { + Data: { + /* worksheet data */ + }, + }, + }; + const mockCsvContent = "Name,Value\nTest,123"; + + global.XLSX.read.mockReturnValue(mockWorkbook); + global.XLSX.utils.sheet_to_csv.mockReturnValue(mockCsvContent); + dataObject.parseCsv = jest + .fn() + .mockReturnValue({ data: [], meta: { fields: [] } }); + + // Test with ArrayBuffer for XLSB file + const arrayBuffer = new ArrayBuffer(200); + dataObject.parseExcel(arrayBuffer, "data.xlsb", "UTF-8"); + + expect(global.XLSX.read).toHaveBeenCalledWith(expect.any(Uint8Array), { + type: "array", + }); + expect(dataObject.parseCsv).toHaveBeenCalledWith( + mockCsvContent, + "UTF-8", + 1, + false, + null, + ); + }); + + test("should handle XLSX files with binary string data type", () => { + const mockWorkbook = { + SheetNames: ["Sheet1"], + Sheets: { + Sheet1: { + /* worksheet data */ + }, + }, + }; + const mockCsvContent = "Header1,Header2\nValue1,Value2"; + + global.XLSX.read.mockReturnValue(mockWorkbook); + global.XLSX.utils.sheet_to_csv.mockReturnValue(mockCsvContent); + dataObject.parseCsv = jest + .fn() + .mockReturnValue({ data: [], meta: { fields: [] } }); + + // Test with binary string for XLSX file + const binaryString = "binary_data_string"; + dataObject.parseExcel(binaryString, "spreadsheet.xlsx", "UTF-8"); + + expect(global.XLSX.read).toHaveBeenCalledWith(binaryString, { + type: "binary", + }); + expect(dataObject.parseCsv).toHaveBeenCalledWith( + mockCsvContent, + "UTF-8", + 1, + false, + null, + ); + }); + + test("should handle XLSM files with binary string data type", () => { + const mockWorkbook = { + SheetNames: ["Macros"], + Sheets: { + Macros: { + /* worksheet data */ + }, + }, + }; + const mockCsvContent = "Function,Result\nSUM,100"; + + global.XLSX.read.mockReturnValue(mockWorkbook); + global.XLSX.utils.sheet_to_csv.mockReturnValue(mockCsvContent); + dataObject.parseCsv = jest + .fn() + .mockReturnValue({ data: [], meta: { fields: [] } }); + + // Test with binary string for XLSM file + const binaryString = "xlsm_binary_data"; + dataObject.parseExcel(binaryString, "macro_file.xlsm", "UTF-8"); + + expect(global.XLSX.read).toHaveBeenCalledWith(binaryString, { + type: "binary", + }); + expect(dataObject.parseCsv).toHaveBeenCalledWith( + mockCsvContent, + "UTF-8", + 1, + false, + null, + ); + }); + + test("should handle corrupted Excel file with meaningful error", () => { + const consoleSpy = jest.spyOn(console, "error").mockImplementation(); + + global.XLSX.read.mockImplementation(() => { + throw new Error("Corrupted file structure"); + }); + + expect(() => { + dataObject.parseExcel("corrupted_data", "test.xlsx", "UTF-8"); + }).toThrow("Failed to parse Excel file: Corrupted file structure"); + + expect(consoleSpy).toHaveBeenCalledWith( + "Error parsing Excel file:", + expect.any(Error), + ); + consoleSpy.mockRestore(); + }); + + test("should handle empty worksheet gracefully", () => { + const consoleSpy = jest.spyOn(console, "error").mockImplementation(); + + const mockWorkbook = { + SheetNames: ["EmptySheet"], + Sheets: { + EmptySheet: null, + }, + }; + + global.XLSX.read.mockReturnValue(mockWorkbook); + + expect(() => { + dataObject.parseExcel("binary_data", "test.xlsx", "UTF-8"); + }).toThrow( + "Failed to parse Excel file: Worksheet not found: EmptySheet", + ); + + expect(consoleSpy).toHaveBeenCalledWith( + "Error parsing Excel file:", + expect.any(Error), + ); + consoleSpy.mockRestore(); + }); + + test("should handle worksheet with special characters in name", () => { + const mockWorkbook = { + SheetNames: ["Data & Analysis", "Summary (Final)"], + Sheets: { + "Data & Analysis": { + /* worksheet data */ + }, + "Summary (Final)": { + /* worksheet data */ + }, + }, + }; + const mockCsvContent = "Special,Data\nTest,Value"; + + global.XLSX.read.mockReturnValue(mockWorkbook); + global.XLSX.utils.sheet_to_csv.mockReturnValue(mockCsvContent); + dataObject.parseCsv = jest + .fn() + .mockReturnValue({ data: [], meta: { fields: [] } }); + + // Test selecting worksheet with special characters + dataObject.parseExcel( + "binary_data", + "test.xlsx", + "UTF-8", + 1, + false, + null, + 1, + ); + + expect(global.XLSX.utils.sheet_to_csv).toHaveBeenCalledWith( + mockWorkbook.Sheets["Summary (Final)"], + ); + expect(dataObject.currentWorksheet).toBe("Summary (Final)"); + }); + + test("should handle very large worksheet index gracefully", () => { + const consoleSpy = jest.spyOn(console, "error").mockImplementation(); + + const mockWorkbook = { + SheetNames: ["Sheet1"], + Sheets: { + Sheet1: { + /* worksheet data */ + }, + }, + }; + + global.XLSX.read.mockReturnValue(mockWorkbook); + + expect(() => { + dataObject.parseExcel( + "binary_data", + "test.xlsx", + "UTF-8", + 1, + false, + null, + 999, + ); + }).toThrow( + "Failed to parse Excel file: Worksheet index 999 is out of range. Available sheets: 1", + ); + + expect(consoleSpy).toHaveBeenCalledWith( + "Error parsing Excel file:", + expect.any(Error), + ); + consoleSpy.mockRestore(); + }); + }); + }); + + describe("fixTwoDigitYear", () => { + test("should convert MM/DD/YY to MM/DD/20YY", () => { + expect(DataObject.fixTwoDigitYear("01/15/24")).toBe("01/15/2024"); + }); + + test("should convert DD.MM.YY to DD.MM.20YY", () => { + expect(DataObject.fixTwoDigitYear("15.01.24")).toBe("15.01.2024"); + }); + + test("should convert DD-MM-YY to DD-MM-20YY", () => { + expect(DataObject.fixTwoDigitYear("15-01-24")).toBe("15-01-2024"); + }); + + test("should convert YY-MM-DD to 20YY-MM-DD when year > 31", () => { + expect(DataObject.fixTwoDigitYear("99-01-15")).toBe("2099-01-15"); + }); + + test("should convert YY/MM/DD to 20YY/MM/DD when year > 31", () => { + expect(DataObject.fixTwoDigitYear("99/01/15")).toBe("2099/01/15"); + }); + + test("should handle single-digit day/month", () => { + expect(DataObject.fixTwoDigitYear("1/5/24")).toBe("1/5/2024"); + }); + + test("should not modify dates that already have 4-digit year", () => { + expect(DataObject.fixTwoDigitYear("01/15/2024")).toBe("01/15/2024"); + expect(DataObject.fixTwoDigitYear("2024-01-15")).toBe("2024-01-15"); + }); + + test("should return null/empty unchanged", () => { + expect(DataObject.fixTwoDigitYear(null)).toBe(null); + expect(DataObject.fixTwoDigitYear("")).toBe(""); + expect(DataObject.fixTwoDigitYear(undefined)).toBe(undefined); + }); + }); + + describe("hasShortYearDates", () => { + test("should return true when majority of dates have 2-digit years", () => { + dataObject.base_json = { + data: [ + { Date: "01/15/24" }, + { Date: "02/20/24" }, + { Date: "03/10/24" }, + ], + }; + + expect(dataObject.hasShortYearDates("Date")).toBe(true); + }); + + test("should return false when dates have 4-digit years", () => { + dataObject.base_json = { + data: [ + { Date: "01/15/2024" }, + { Date: "02/20/2024" }, + { Date: "03/10/2024" }, + ], + }; + + expect(dataObject.hasShortYearDates("Date")).toBe(false); + }); + + test("should return false when no data", () => { + dataObject.base_json = null; + expect(dataObject.hasShortYearDates("Date")).toBe(false); + }); + + test("should return false when column name is empty", () => { + dataObject.base_json = { data: [{ Date: "01/15/24" }] }; + expect(dataObject.hasShortYearDates("")).toBe(false); + expect(dataObject.hasShortYearDates(null)).toBe(false); + }); + + test("should only sample first 10 rows", () => { + const rows = []; + for (let i = 0; i < 20; i++) { + rows.push({ Date: "01/15/24" }); + } + dataObject.base_json = { data: rows }; + + expect(dataObject.hasShortYearDates("Date")).toBe(true); + }); + }); + + describe("converted_json with fix_dates", () => { + beforeEach(() => { + dataObject.base_json = { + data: [ + { Date: "01/15/24", Description: "Purchase", Amount: "-50.00" }, + { Date: "02/20/24", Description: "Salary", Amount: "1000.00" }, + ], + meta: { fields: ["Date", "Description", "Amount"] }, + }; + }); + + test("should fix dates when fix_dates is true", () => { + const ynab_cols = ["Date", "Payee", "Memo", "Amount"]; + const lookup = { + Date: "Date", + Payee: "Description", + Memo: "Description", + Amount: "Amount", + }; + + const result = dataObject.converted_json( + null, + ynab_cols, + lookup, + false, + false, + true, + ); + + expect(result[0].Date).toBe("01/15/2024"); + expect(result[1].Date).toBe("02/20/2024"); + }); + + test("should not fix dates when fix_dates is false", () => { + const ynab_cols = ["Date", "Payee", "Memo", "Amount"]; + const lookup = { + Date: "Date", + Payee: "Description", + Memo: "Description", + Amount: "Amount", + }; + + const result = dataObject.converted_json( + null, + ynab_cols, + lookup, + false, + false, + false, + ); + + expect(result[0].Date).toBe("01/15/24"); + expect(result[1].Date).toBe("02/20/24"); }); }); -}); \ No newline at end of file +}); diff --git a/tests/directives.test.js b/tests/directives.test.js index c1ceb6e..bd281dc 100644 --- a/tests/directives.test.js +++ b/tests/directives.test.js @@ -2,27 +2,27 @@ const mockModule = { directive: jest.fn(() => mockModule), config: jest.fn(() => mockModule), - controller: jest.fn(() => mockModule) + controller: jest.fn(() => mockModule), }; global.angular = { element: jest.fn(() => ({ - ready: jest.fn((callback) => callback()) + ready: jest.fn((callback) => callback()), })), module: jest.fn(() => mockModule), - bootstrap: jest.fn() + bootstrap: jest.fn(), }; -describe('AngularJS Directives', () => { +describe("AngularJS Directives", () => { beforeEach(() => { jest.clearAllMocks(); jest.resetModules(); - + // Load the app.js file to register the directives - require('../src/app.js'); + require("../src/app.js"); }); - describe('fileread directive', () => { + describe("fileread directive", () => { let directiveFactory; let mockElement; let mockScope; @@ -31,103 +31,116 @@ describe('AngularJS Directives', () => { beforeEach(() => { // Get the fileread directive factory const directiveCalls = mockModule.directive.mock.calls; - const filereadCall = directiveCalls.find(call => call[0] === 'fileread'); + const filereadCall = directiveCalls.find( + (call) => call[0] === "fileread", + ); // The directive is defined as an array with the function as the last element directiveFactory = filereadCall[1][0]; // Create mocks mockScope = { fileread: null, - $apply: jest.fn((fn) => fn && fn()) + $apply: jest.fn((fn) => fn && fn()), }; - + mockElement = { - bind: jest.fn() + bind: jest.fn(), }; - + mockAttributes = { - encoding: 'UTF-8' + encoding: "UTF-8", }; }); - test('should register fileread directive', () => { + test("should register fileread directive", () => { expect(directiveFactory).toBeDefined(); - expect(typeof directiveFactory).toBe('function'); + expect(typeof directiveFactory).toBe("function"); }); - test('should configure directive with correct scope', () => { + test("should configure directive with correct scope", () => { const directiveConfig = directiveFactory(); - + expect(directiveConfig.scope).toEqual({ - fileread: "=" + fileread: "=", + filename: "=", }); }); - test('should bind change event to element', () => { + test("should bind change event to element", () => { const directiveConfig = directiveFactory(); directiveConfig.link(mockScope, mockElement, mockAttributes); - - expect(mockElement.bind).toHaveBeenCalledWith('change', expect.any(Function)); + + expect(mockElement.bind).toHaveBeenCalledWith( + "change", + expect.any(Function), + ); }); - test('should process file when change event occurs', () => { + test("should process file when change event occurs", () => { const directiveConfig = directiveFactory(); directiveConfig.link(mockScope, mockElement, mockAttributes); - + // Get the change event handler const changeHandler = mockElement.bind.mock.calls[0][1]; - + // Mock FileReader const mockReader = { onload: null, - readAsText: jest.fn() + readAsText: jest.fn(), }; global.FileReader = jest.fn(() => mockReader); - + // Mock event const mockEvent = { target: { - files: [{ name: 'test.csv', size: 100 }] - } + files: [{ name: "test.csv", size: 100 }], + }, }; - + // Execute change handler changeHandler(mockEvent); - + expect(global.FileReader).toHaveBeenCalled(); - expect(mockReader.readAsText).toHaveBeenCalledWith(mockEvent.target.files[0], 'UTF-8'); + expect(mockReader.readAsText).toHaveBeenCalledWith( + mockEvent.target.files[0], + "UTF-8", + ); }); - test('should update scope when file is loaded', () => { + test("should update scope when file is loaded", () => { const directiveConfig = directiveFactory(); directiveConfig.link(mockScope, mockElement, mockAttributes); - + const changeHandler = mockElement.bind.mock.calls[0][1]; - + const mockReader = { onload: null, - readAsText: jest.fn() + readAsText: jest.fn(), }; global.FileReader = jest.fn(() => mockReader); - + const mockEvent = { - target: { files: [{ name: 'test.csv' }] } + target: { files: [{ name: "test.csv" }] }, }; - + changeHandler(mockEvent); - + // Simulate file load const loadEvent = { - target: { result: 'csv,data\ntest,value' } + target: { result: "csv,data\ntest,value" }, }; mockReader.onload(loadEvent); - + expect(mockScope.$apply).toHaveBeenCalled(); - expect(mockScope.fileread).toBe('csv,data\ntest,value'); + // For CSV files, the data is wrapped in an object with filename + expect(mockScope.fileread).toEqual({ + data: "csv,data\ntest,value", + filename: "test.csv", + }); }); }); - describe('dropzone directive', () => { + describe("dropzone directive", () => { let directiveFactory; let mockElement; let mockScope; @@ -136,201 +149,392 @@ describe('AngularJS Directives', () => { beforeEach(() => { // Get the dropzone directive factory const directiveCalls = mockModule.directive.mock.calls; - const dropzoneCall = directiveCalls.find(call => call[0] === 'dropzone'); + const dropzoneCall = directiveCalls.find( + (call) => call[0] === "dropzone", + ); // The directive is defined as an array with the function as the last element directiveFactory = dropzoneCall[1][0]; mockScope = { dropzone: null, - $apply: jest.fn((fn) => fn && fn()) + $apply: jest.fn((fn) => fn && fn()), }; - + mockElement = { bind: jest.fn(), addClass: jest.fn(), - removeClass: jest.fn() + removeClass: jest.fn(), }; - + mockAttributes = { - encoding: 'UTF-8' + encoding: "UTF-8", }; }); - test('should register dropzone directive', () => { + test("should register dropzone directive", () => { expect(directiveFactory).toBeDefined(); - expect(typeof directiveFactory).toBe('function'); + expect(typeof directiveFactory).toBe("function"); }); - test('should configure directive correctly', () => { + test("should configure directive correctly", () => { const directiveConfig = directiveFactory(); - + expect(directiveConfig.transclude).toBe(true); expect(directiveConfig.replace).toBe(true); - expect(directiveConfig.template).toBe('
'); + expect(directiveConfig.template).toBe( + '
', + ); expect(directiveConfig.scope).toEqual({ - dropzone: "=" + dropzone: "=", + filename: "=", }); }); - test('should bind drag and drop events', () => { + test("should bind drag and drop events", () => { const directiveConfig = directiveFactory(); directiveConfig.link(mockScope, mockElement, mockAttributes); - - const expectedEvents = ['dragenter', 'dragover', 'dragleave', 'drop', 'paste']; - expectedEvents.forEach(eventName => { - expect(mockElement.bind).toHaveBeenCalledWith(eventName, expect.any(Function)); + + const expectedEvents = [ + "dragenter", + "dragover", + "dragleave", + "drop", + "paste", + ]; + expectedEvents.forEach((eventName) => { + expect(mockElement.bind).toHaveBeenCalledWith( + eventName, + expect.any(Function), + ); }); }); - test('should handle dragenter event', () => { + test("should handle dragenter event", () => { const directiveConfig = directiveFactory(); directiveConfig.link(mockScope, mockElement, mockAttributes); - + // Get the dragenter handler const bindCalls = mockElement.bind.mock.calls; - const dragenterCall = bindCalls.find(call => call[0] === 'dragenter'); + const dragenterCall = bindCalls.find((call) => call[0] === "dragenter"); const dragenterHandler = dragenterCall[1]; - + const mockEvent = { - preventDefault: jest.fn() + preventDefault: jest.fn(), }; - + dragenterHandler(mockEvent); - - expect(mockElement.addClass).toHaveBeenCalledWith('dragging'); + + expect(mockElement.addClass).toHaveBeenCalledWith("dragging"); expect(mockEvent.preventDefault).toHaveBeenCalled(); }); - test('should handle dragover event', () => { + test("should handle dragover event", () => { const directiveConfig = directiveFactory(); directiveConfig.link(mockScope, mockElement, mockAttributes); - + const bindCalls = mockElement.bind.mock.calls; - const dragoverCall = bindCalls.find(call => call[0] === 'dragover'); + const dragoverCall = bindCalls.find((call) => call[0] === "dragover"); const dragoverHandler = dragoverCall[1]; - + const mockEvent = { preventDefault: jest.fn(), stopPropagation: jest.fn(), dataTransfer: { - effectAllowed: 'move', - dropEffect: null - } + effectAllowed: "move", + dropEffect: null, + }, }; - + dragoverHandler(mockEvent); - - expect(mockElement.addClass).toHaveBeenCalledWith('dragging'); + + expect(mockElement.addClass).toHaveBeenCalledWith("dragging"); expect(mockEvent.preventDefault).toHaveBeenCalled(); expect(mockEvent.stopPropagation).toHaveBeenCalled(); - expect(mockEvent.dataTransfer.dropEffect).toBe('move'); + expect(mockEvent.dataTransfer.dropEffect).toBe("move"); }); - test('should handle dragleave event', () => { + test("should handle dragleave event", () => { const directiveConfig = directiveFactory(); directiveConfig.link(mockScope, mockElement, mockAttributes); - + const bindCalls = mockElement.bind.mock.calls; - const dragleaveCall = bindCalls.find(call => call[0] === 'dragleave'); + const dragleaveCall = bindCalls.find((call) => call[0] === "dragleave"); const dragleaveHandler = dragleaveCall[1]; - + const mockEvent = { - preventDefault: jest.fn() + preventDefault: jest.fn(), }; - + dragleaveHandler(mockEvent); - - expect(mockElement.removeClass).toHaveBeenCalledWith('dragging'); + + expect(mockElement.removeClass).toHaveBeenCalledWith("dragging"); expect(mockEvent.preventDefault).toHaveBeenCalled(); }); - test('should handle drop event', () => { + test("should handle drop event", () => { const directiveConfig = directiveFactory(); directiveConfig.link(mockScope, mockElement, mockAttributes); - + const bindCalls = mockElement.bind.mock.calls; - const dropCall = bindCalls.find(call => call[0] === 'drop'); + const dropCall = bindCalls.find((call) => call[0] === "drop"); const dropHandler = dropCall[1]; - + const mockReader = { onload: null, - readAsText: jest.fn() + readAsText: jest.fn(), }; global.FileReader = jest.fn(() => mockReader); - + // Mock the global 'file' variable that's used in the directive global.file = null; - + const mockEvent = { preventDefault: jest.fn(), stopPropagation: jest.fn(), dataTransfer: { - files: [{ name: 'dropped.csv', size: 200 }] - } + files: [{ name: "dropped.csv", size: 200 }], + }, }; - + dropHandler(mockEvent); - - expect(mockElement.removeClass).toHaveBeenCalledWith('dragging'); + + expect(mockElement.removeClass).toHaveBeenCalledWith("dragging"); expect(mockEvent.preventDefault).toHaveBeenCalled(); expect(mockEvent.stopPropagation).toHaveBeenCalled(); expect(global.FileReader).toHaveBeenCalled(); - expect(mockReader.readAsText).toHaveBeenCalledWith(mockEvent.dataTransfer.files[0], 'UTF-8'); + expect(mockReader.readAsText).toHaveBeenCalledWith( + mockEvent.dataTransfer.files[0], + "UTF-8", + ); }); - test('should handle paste event with text data', () => { + test("should handle paste event with text data", () => { const directiveConfig = directiveFactory(); directiveConfig.link(mockScope, mockElement, mockAttributes); - + const bindCalls = mockElement.bind.mock.calls; - const pasteCall = bindCalls.find(call => call[0] === 'paste'); + const pasteCall = bindCalls.find((call) => call[0] === "paste"); const pasteHandler = pasteCall[1]; - + // Mock the global 'data' variable that's used in the directive global.data = null; - + const mockDataItem = { - type: 'text/plain', - getAsString: jest.fn((callback) => callback('pasted,csv,data\nrow1,row2,row3')) + type: "text/plain", + getAsString: jest.fn((callback) => + callback("pasted,csv,data\nrow1,row2,row3"), + ), }; - + const mockEvent = { clipboardData: { - items: [mockDataItem] - } + items: [mockDataItem], + }, }; - + pasteHandler(mockEvent); - - expect(mockDataItem.getAsString).toHaveBeenCalledWith(expect.any(Function)); + + expect(mockDataItem.getAsString).toHaveBeenCalledWith( + expect.any(Function), + ); expect(mockScope.$apply).toHaveBeenCalled(); - expect(mockScope.dropzone).toBe('pasted,csv,data\nrow1,row2,row3'); + expect(mockScope.dropzone).toBe("pasted,csv,data\nrow1,row2,row3"); }); - test('should handle paste event with no text data', () => { + test("should handle paste event with no text data", () => { const directiveConfig = directiveFactory(); directiveConfig.link(mockScope, mockElement, mockAttributes); - + const bindCalls = mockElement.bind.mock.calls; - const pasteCall = bindCalls.find(call => call[0] === 'paste'); + const pasteCall = bindCalls.find((call) => call[0] === "paste"); const pasteHandler = pasteCall[1]; - + // Mock the global 'data' variable that's used in the directive global.data = null; - + const mockEvent = { clipboardData: { - items: [ - { type: 'image/png' }, - { type: 'application/json' } - ] - } + items: [{ type: "image/png" }, { type: "application/json" }], + }, }; - + pasteHandler(mockEvent); - + // Should not call $apply since no text/plain data found expect(mockScope.$apply).not.toHaveBeenCalled(); }); }); -}); \ No newline at end of file + + describe("fileParsingSettings directive", () => { + let directiveFactory; + let mockScope; + let mockElement; + let mockAttributes; + + beforeEach(() => { + // Get the fileParsingSettings directive factory + const directiveCalls = mockModule.directive.mock.calls; + const fileParsingSettingsCall = directiveCalls.find( + (call) => call[0] === "fileParsingSettings", + ); + // The directive is defined as an array with the function as the last element + directiveFactory = fileParsingSettingsCall[1][0]; + + mockScope = { + file: { + encodings: ["UTF-8", "ISO-8859-1"], + delimiters: ["auto", ",", ";"], + chosenEncoding: "UTF-8", + chosenDelimiter: "auto", + startAtRow: 1, + extraRow: false, + }, + profiles: { "default profile": {} }, + profileName: "default profile", + onEncodingChange: jest.fn(), + onDelimiterChange: jest.fn(), + onStartRowChange: jest.fn(), + onExtraRowChange: jest.fn(), + onProfileChange: jest.fn(), + showProfiles: false, + worksheetNames: null, + selectedWorksheet: 0, + onWorksheetChange: jest.fn(), + }; + + mockElement = {}; + mockAttributes = {}; + }); + + test("should register fileParsingSettings directive", () => { + expect(directiveFactory).toBeDefined(); + expect(typeof directiveFactory).toBe("function"); + }); + + test("should configure directive correctly", () => { + const directiveConfig = directiveFactory(); + + expect(directiveConfig.restrict).toBe("E"); + expect(directiveConfig.scope).toEqual({ + file: "=", + profiles: "=", + profileName: "=", + onEncodingChange: "&", + onDelimiterChange: "&", + onStartRowChange: "&", + onExtraRowChange: "&", + onProfileChange: "&", + showProfiles: "=", + worksheetNames: "=", + selectedWorksheet: "=", + onWorksheetChange: "&", + }); + expect(typeof directiveConfig.template).toBe("string"); + expect(directiveConfig.template).toContain("file-parsing-settings"); + }); + + test("should generate unique ID in link function", () => { + const directiveConfig = directiveFactory(); + const scope = { ...mockScope }; + + directiveConfig.link(scope, mockElement, mockAttributes); + + expect(scope.uniqueId).toBeDefined(); + expect(typeof scope.uniqueId).toBe("string"); + expect(scope.uniqueId).toMatch(/^fps-[a-z0-9]{9}$/); + }); + + test("should render all form elements in template", () => { + const directiveConfig = directiveFactory(); + const template = directiveConfig.template; + + // Check for encoding select + expect(template).toContain("Character encoding"); + expect(template).toContain('ng-model="file.chosenEncoding"'); + expect(template).toContain('data-testid="encoding-select"'); + + // Check for delimiter select + expect(template).toContain("Cell delimiter"); + expect(template).toContain('ng-model="file.chosenDelimiter"'); + expect(template).toContain('data-testid="delimiter-select"'); + + // Check for start row input + expect(template).toContain("Start at row"); + expect(template).toContain('ng-model="file.startAtRow"'); + expect(template).toContain('data-testid="start-row-input"'); + + // Check for extra row checkbox + expect(template).toContain("Fill header row from first line"); + expect(template).toContain('ng-model="file.extraRow"'); + expect(template).toContain('data-testid="extra-row-checkbox"'); + + // Check for profile select (conditional) + expect(template).toContain("Bank profile"); + expect(template).toContain('ng-if="showProfiles"'); + expect(template).toContain('data-testid="profile-select"'); + + // Check for worksheet select (conditional) + expect(template).toContain("Excel worksheet"); + expect(template).toContain( + 'ng-if="worksheetNames && worksheetNames.length > 1"', + ); + expect(template).toContain('data-testid="worksheet-select"'); + }); + + test("should bind callbacks correctly", () => { + const directiveConfig = directiveFactory(); + const template = directiveConfig.template; + + // Check encoding change callback + expect(template).toContain( + 'ng-change="onEncodingChange({encoding: file.chosenEncoding})"', + ); + + // Check delimiter change callback + expect(template).toContain( + 'ng-change="onDelimiterChange({delimiter: file.chosenDelimiter})"', + ); + + // Check start row change callback + expect(template).toContain( + 'ng-change="onStartRowChange({row: file.startAtRow})"', + ); + + // Check extra row change callback + expect(template).toContain( + 'ng-change="onExtraRowChange({extraRow: file.extraRow})"', + ); + + // Check profile change callback + expect(template).toContain( + 'ng-change="onProfileChange({profileName: profileName})"', + ); + + // Check worksheet change callback + expect(template).toContain( + 'ng-change="onWorksheetChange({worksheet: selectedWorksheet})"', + ); + }); + + test("should use unique IDs for form elements", () => { + const directiveConfig = directiveFactory(); + const template = directiveConfig.template; + + // Check that all form elements use unique ID pattern + expect(template).toContain('id="{{::uniqueId}}-encoding"'); + expect(template).toContain('for="{{::uniqueId}}-encoding"'); + expect(template).toContain('id="{{::uniqueId}}-delimiter"'); + expect(template).toContain('for="{{::uniqueId}}-delimiter"'); + expect(template).toContain('id="{{::uniqueId}}-start-row"'); + expect(template).toContain('for="{{::uniqueId}}-start-row"'); + expect(template).toContain('id="{{::uniqueId}}-extra-row"'); + expect(template).toContain('for="{{::uniqueId}}-extra-row"'); + expect(template).toContain('id="{{::uniqueId}}-profile"'); + expect(template).toContain('for="{{::uniqueId}}-profile"'); + expect(template).toContain('id="{{::uniqueId}}-worksheet"'); + expect(template).toContain('for="{{::uniqueId}}-worksheet"'); + }); + }); + + // TODO: Add Excel directive tests (currently complex due to sophisticated file handling logic) +}); diff --git a/tests/file_utils.test.js b/tests/file_utils.test.js new file mode 100644 index 0000000..cad8ab4 --- /dev/null +++ b/tests/file_utils.test.js @@ -0,0 +1,239 @@ +describe("FileUtils", () => { + let FileUtils; + + beforeEach(() => { + // Clear any cached modules to ensure clean state + delete require.cache[require.resolve("../src/file_utils.js")]; + // Re-require the module + FileUtils = require("../src/file_utils.js"); + }); + + describe("getFileExtension", () => { + test("should extract file extension correctly", () => { + expect(FileUtils.getFileExtension("test.xlsx")).toBe("xlsx"); + expect(FileUtils.getFileExtension("data.csv")).toBe("csv"); + expect(FileUtils.getFileExtension("file.XLS")).toBe("xls"); + expect(FileUtils.getFileExtension("document.XLSM")).toBe("xlsm"); + }); + + test("should handle edge cases", () => { + expect(FileUtils.getFileExtension("")).toBe(""); + expect(FileUtils.getFileExtension(null)).toBe(""); + expect(FileUtils.getFileExtension(undefined)).toBe(""); + expect(FileUtils.getFileExtension("noextension")).toBe("noextension"); + expect(FileUtils.getFileExtension("file.")).toBe(""); + expect(FileUtils.getFileExtension(".hidden")).toBe("hidden"); + }); + + test("should handle multiple dots", () => { + expect(FileUtils.getFileExtension("my.file.name.xlsx")).toBe("xlsx"); + expect(FileUtils.getFileExtension("archive.tar.gz")).toBe("gz"); + }); + }); + + describe("isExcelFile", () => { + test("should identify Excel files correctly", () => { + expect(FileUtils.isExcelFile("test.xlsx")).toBe(true); + expect(FileUtils.isExcelFile("data.xls")).toBe(true); + expect(FileUtils.isExcelFile("file.xlsm")).toBe(true); + expect(FileUtils.isExcelFile("workbook.xlsb")).toBe(true); + }); + + test("should handle case insensitivity", () => { + expect(FileUtils.isExcelFile("TEST.XLSX")).toBe(true); + expect(FileUtils.isExcelFile("Data.XLS")).toBe(true); + expect(FileUtils.isExcelFile("FILE.Xlsm")).toBe(true); + expect(FileUtils.isExcelFile("WORKBOOK.xlsB")).toBe(true); + }); + + test("should reject non-Excel files", () => { + expect(FileUtils.isExcelFile("data.csv")).toBe(false); + expect(FileUtils.isExcelFile("document.txt")).toBe(false); + expect(FileUtils.isExcelFile("image.png")).toBe(false); + expect(FileUtils.isExcelFile("data.json")).toBe(false); + }); + + test("should handle edge cases", () => { + expect(FileUtils.isExcelFile("")).toBe(false); + expect(FileUtils.isExcelFile(null)).toBe(false); + expect(FileUtils.isExcelFile(undefined)).toBe(false); + expect(FileUtils.isExcelFile("noextension")).toBe(false); + expect(FileUtils.isExcelFile("file.")).toBe(false); + }); + }); + + describe("getExcelReadingMethod", () => { + test("should return arrayBuffer for binary formats", () => { + expect(FileUtils.getExcelReadingMethod("data.xls")).toBe("arrayBuffer"); + expect(FileUtils.getExcelReadingMethod("workbook.xlsb")).toBe( + "arrayBuffer", + ); + expect(FileUtils.getExcelReadingMethod("FILE.XLS")).toBe("arrayBuffer"); + expect(FileUtils.getExcelReadingMethod("WORKBOOK.XLSB")).toBe( + "arrayBuffer", + ); + }); + + test("should return binaryString for ZIP-based formats", () => { + expect(FileUtils.getExcelReadingMethod("data.xlsx")).toBe("binaryString"); + expect(FileUtils.getExcelReadingMethod("workbook.xlsm")).toBe( + "binaryString", + ); + expect(FileUtils.getExcelReadingMethod("FILE.XLSX")).toBe("binaryString"); + expect(FileUtils.getExcelReadingMethod("WORKBOOK.XLSM")).toBe( + "binaryString", + ); + }); + + test("should handle non-Excel files", () => { + // Non-Excel files should default to binaryString + expect(FileUtils.getExcelReadingMethod("data.csv")).toBe("binaryString"); + expect(FileUtils.getExcelReadingMethod("document.txt")).toBe( + "binaryString", + ); + expect(FileUtils.getExcelReadingMethod("")).toBe("binaryString"); + }); + }); + + describe("getFileType", () => { + test("should return uppercase extension for Excel files", () => { + expect(FileUtils.getFileType("test.xlsx")).toBe("XLSX"); + expect(FileUtils.getFileType("data.xls")).toBe("XLS"); + expect(FileUtils.getFileType("file.xlsm")).toBe("XLSM"); + expect(FileUtils.getFileType("workbook.xlsb")).toBe("XLSB"); + }); + + test("should handle case insensitivity for Excel files", () => { + expect(FileUtils.getFileType("TEST.xlsx")).toBe("XLSX"); + expect(FileUtils.getFileType("Data.XLS")).toBe("XLS"); + expect(FileUtils.getFileType("FILE.Xlsm")).toBe("XLSM"); + expect(FileUtils.getFileType("WORKBOOK.xlsB")).toBe("XLSB"); + }); + + test("should return CSV for non-Excel files", () => { + expect(FileUtils.getFileType("data.csv")).toBe("CSV"); + expect(FileUtils.getFileType("document.txt")).toBe("CSV"); + expect(FileUtils.getFileType("image.png")).toBe("CSV"); + expect(FileUtils.getFileType("noextension")).toBe("CSV"); + }); + + test("should handle edge cases", () => { + expect(FileUtils.getFileType("")).toBe(""); + expect(FileUtils.getFileType(null)).toBe(""); + expect(FileUtils.getFileType(undefined)).toBe(""); + }); + }); + + describe("createDataWrapper", () => { + test("should create data wrapper with content and filename", () => { + const content = "test data content"; + const filename = "test.csv"; + + const wrapper = FileUtils.createDataWrapper(content, filename); + + expect(wrapper).toEqual({ + data: "test data content", + filename: "test.csv", + }); + }); + + test("should handle different content types", () => { + // Test with different data types + const binaryData = new ArrayBuffer(8); + const wrapper1 = FileUtils.createDataWrapper(binaryData, "test.xlsx"); + expect(wrapper1.data).toBe(binaryData); + expect(wrapper1.filename).toBe("test.xlsx"); + + const textData = "csv,data,here"; + const wrapper2 = FileUtils.createDataWrapper(textData, "data.csv"); + expect(wrapper2.data).toBe(textData); + expect(wrapper2.filename).toBe("data.csv"); + }); + + test("should handle empty or null values", () => { + const wrapper1 = FileUtils.createDataWrapper("", ""); + expect(wrapper1.data).toBe(""); + expect(wrapper1.filename).toBe(""); + + const wrapper2 = FileUtils.createDataWrapper(null, null); + expect(wrapper2.data).toBe(null); + expect(wrapper2.filename).toBe(null); + }); + }); + + describe("constants", () => { + test("should provide file extension constants", () => { + const constants = FileUtils.constants(); + + expect(constants.EXCEL_EXTENSIONS).toEqual([ + "xlsx", + "xls", + "xlsm", + "xlsb", + ]); + expect(constants.BINARY_FORMAT_EXTENSIONS).toEqual(["xls", "xlsb"]); + }); + + test("should return immutable constants", () => { + const constants1 = FileUtils.constants(); + const constants2 = FileUtils.constants(); + + // Should return the same arrays each time + expect(constants1.EXCEL_EXTENSIONS).toEqual(constants2.EXCEL_EXTENSIONS); + expect(constants1.BINARY_FORMAT_EXTENSIONS).toEqual( + constants2.BINARY_FORMAT_EXTENSIONS, + ); + }); + }); + + describe("integration tests", () => { + test("should work together consistently", () => { + const testFiles = [ + { + name: "data.xlsx", + isExcel: true, + method: "binaryString", + type: "XLSX", + }, + { + name: "workbook.xls", + isExcel: true, + method: "arrayBuffer", + type: "XLS", + }, + { + name: "file.xlsm", + isExcel: true, + method: "binaryString", + type: "XLSM", + }, + { + name: "doc.xlsb", + isExcel: true, + method: "arrayBuffer", + type: "XLSB", + }, + { + name: "data.csv", + isExcel: false, + method: "binaryString", + type: "CSV", + }, + { + name: "text.txt", + isExcel: false, + method: "binaryString", + type: "CSV", + }, + ]; + + testFiles.forEach((testFile) => { + expect(FileUtils.isExcelFile(testFile.name)).toBe(testFile.isExcel); + expect(FileUtils.getExcelReadingMethod(testFile.name)).toBe( + testFile.method, + ); + expect(FileUtils.getFileType(testFile.name)).toBe(testFile.type); + }); + }); + }); +});