diff --git a/packages/shared/src/evaluation/experiment-service.ts b/packages/shared/src/evaluation/experiment-service.ts index 41daf4f..08a64e3 100644 --- a/packages/shared/src/evaluation/experiment-service.ts +++ b/packages/shared/src/evaluation/experiment-service.ts @@ -531,7 +531,7 @@ export class ExperimentService { }; await this.traceRun(failedRun, session.id, caseItem.locale); await this.kernel.deleteSession(session.id).catch(() => undefined); - return { run: failedRun, aborted: false }; + return { run: failedRun, aborted: false, runtimeUnusable: false }; } const collected: AgentEvent[] = []; diff --git a/packages/shared/src/portfolio-import/parsers.test.ts b/packages/shared/src/portfolio-import/parsers.test.ts index 0f4059a..0c9a51c 100644 --- a/packages/shared/src/portfolio-import/parsers.test.ts +++ b/packages/shared/src/portfolio-import/parsers.test.ts @@ -108,6 +108,30 @@ describe('splitCsvLine', () => { }) describe('parseCsv', () => { + it('keeps thousands separators inside tab-delimited numeric cells', () => { + const [row] = parseCsv('Symbol\tQuantity\tCost\tCurrency\nAAPL.US\t1,000\t1,234.50\tUSD') + expect(row).toMatchObject({ symbol: 'AAPL.US', quantity: 1000, costPrice: 1234.5, currency: 'USD', confidence: 1, issues: [] }) + }) + + it('uses the header delimiter even when data contains commas and quoted newlines', () => { + const [row] = parseCsv('Symbol\tName\tQuantity\tCost\nAAPL.US\t"Apple,\nInc."\t100\t180.5') + expect(row).toMatchObject({ name: 'Apple,\nInc.', quantity: 100, costPrice: 180.5, issues: [] }) + const [plain] = parseCsv('Symbol\tName\tQuantity\tCost\nAAPL.US\tApple, Inc.\t100\t180.5') + expect(plain).toMatchObject({ name: 'Apple, Inc.', quantity: 100, costPrice: 180.5, issues: [] }) + }) + + it('does not split embedded tabs in comma-delimited fields', () => { + const [row] = parseCsv('Symbol,Name,Quantity,Cost\nAAPL.US,Apple\tInc.,100,180.5') + expect(row).toMatchObject({ name: 'Apple\tInc.', quantity: 100, costPrice: 180.5, issues: [] }) + }) + + it('supports headerless TSV and explicit TSV header mappings', () => { + const [row] = parseCsv('AAPL.US\tApple, Inc.\t1,000\t180.5') + expect(row).toMatchObject({ name: 'Apple, Inc.', quantity: 1000, costPrice: 180.5, issues: [] }) + const [mapped] = parseCsv('Ticker\tUnits\tAvg Cost\nAAPL.US\t1,000\t180.5', { symbol: 'Ticker', quantity: 'Units', cost: 'Avg Cost' }) + expect(mapped).toMatchObject({ symbol: 'AAPL.US', quantity: 1000, costPrice: 180.5, issues: [] }) + }) + it('flags formatting-only quantities and costs without inventing zeros', () => { const rows = parseCsv('Symbol,Quantity,Cost\nAAPL.US,100,$\nMSFT.US,",",180.5\n0700.HK,500,320') expect(rows).toHaveLength(3) diff --git a/packages/shared/src/portfolio-import/parsers.ts b/packages/shared/src/portfolio-import/parsers.ts index cc96ca2..c8fa1dc 100644 --- a/packages/shared/src/portfolio-import/parsers.ts +++ b/packages/shared/src/portfolio-import/parsers.ts @@ -130,8 +130,18 @@ export function splitCsvRows(text: string): string[] { return rows } -/** Split one CSV row into cells (comma or tab separated, quote-aware). */ -export function splitCsvLine(line: string): string[] { +/** Use the first unquoted separator; quoted punctuation belongs to a cell. */ +function csvDelimiter(line: string): ',' | '\t' { + let inQuotes = false + for (const ch of line) { + if (ch === '"') inQuotes = !inQuotes + else if (!inQuotes && (ch === ',' || ch === '\t')) return ch + } + return ',' +} + +/** Split one CSV row using one delimiter, honoring quoted cells. */ +export function splitCsvLine(line: string, delimiter: ',' | '\t' = csvDelimiter(line)): string[] { const cells: string[] = [] let current = '' let inQuotes = false @@ -150,7 +160,7 @@ export function splitCsvLine(line: string): string[] { } } else if (ch === '"') { inQuotes = true - } else if (ch === ',' || ch === '\t') { + } else if (ch === delimiter) { cells.push(current) current = '' } else { @@ -180,9 +190,10 @@ export interface CsvColumnMap { */ export function resolveCsvColumns( firstRow: string, - headerMapping?: CsvHeaderMapping + headerMapping?: CsvHeaderMapping, + delimiter: ',' | '\t' = csvDelimiter(firstRow) ): CsvColumnMap { - const cells = splitCsvLine(firstRow).map(normalizeHeaderCell) + const cells = splitCsvLine(firstRow, delimiter).map(normalizeHeaderCell) const byIndex = new Map() const fields = Object.keys(HEADER_ALIASES) as HeaderField[] @@ -315,12 +326,13 @@ export function flagDuplicates(rows: PortfolioImportRow[]): PortfolioImportRow[] export function parseCsv(text: string, headerMapping?: CsvHeaderMapping): PortfolioImportRow[] { const rawRows = splitCsvRows(text) if (rawRows.length === 0) return [] - const columns = resolveCsvColumns(rawRows[0], headerMapping) + const delimiter = csvDelimiter(rawRows[0]) + const columns = resolveCsvColumns(rawRows[0], headerMapping, delimiter) const start = columns.isHeader ? 1 : 0 const rows: PortfolioImportRow[] = [] for (let i = start; i < rawRows.length; i++) { - const cells = splitCsvLine(rawRows[i]) + const cells = splitCsvLine(rawRows[i], delimiter) if (cells.every((cell) => cell.trim() === '')) continue rows.push(buildRow(fieldsFromCells(cells, columns.byIndex))) } diff --git a/packages/shared/src/portfolio-import/repository.test.ts b/packages/shared/src/portfolio-import/repository.test.ts index dbdaad6..cb15011 100644 --- a/packages/shared/src/portfolio-import/repository.test.ts +++ b/packages/shared/src/portfolio-import/repository.test.ts @@ -21,6 +21,14 @@ const INPUT = { } describe('ManualPortfolioRepository', () => { + it('persists TSV quantities and costs without splitting thousands separators', async () => { + const store = tempStore() + const draft = createDraft('csv', parseCsv('Symbol\tQuantity\tCost\tCurrency\nAAPL.US\t1,000\t1,234.50\tUSD')) + const created = await new ManualPortfolioRepository(store).create(draftToPortfolioInput(draft, 'TSV')) + const reloaded = await new ManualPortfolioRepository(store).get(created.id) + expect(reloaded?.holdings).toEqual([{ symbol: 'AAPL.US', name: '', quantity: 1000, costPrice: 1234.5, currency: 'USD' }]) + }) + it('keeps invalid numeric cells absent through draft review and persistence', async () => { const store = tempStore() const draft = createDraft('csv', parseCsv('Symbol,Quantity,Cost\nAAPL.US,100,$\nMSFT.US,",",180.5'))