Em dashes (—) and en dashes (–) had spread across comments, docs, tests, and a few UI strings, reading as AI-generated boilerplate rather than house style. Replaced each with punctuation matching its context: colon for explanatory clauses, comma for asides, plain hyphen for numeric/legal ranges (e.g. "21-23§"), "to"/"till" for date ranges, parentheses for paired-dash asides. messages/en.json and messages/sv.json were fixed by hand together to keep sv/en in sync. Left untouched where the dash is the functional subject rather than decorative punctuation: date-range-parser.ts's separator regex, charset-repair.ts's CP1252 byte-mapping table (and its test), the SIE encoding mojibake docs, generic-csv.ts's minus-sign normalizer, the agent system-prompt files that already instruct against em dashes, and a golden iXBRL test fixture compared byte-for-byte. Also fixes two bugs surfaced along the way: an off-by-one in ApiKeysPanel's scope-label split (a leftover from an earlier partial pass), and a charset-repair test that had lost the literal en-dash it exists to verify. Regenerated the agent atom seed migration (skills:generate) since 27 SKILL.md files changed. Added a CLAUDE.md rule against em/en dashes, with an explicit carve-out for the functional-dash cases above. Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
231 lines
9.0 KiB
TypeScript
231 lines
9.0 KiB
TypeScript
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
|
import { parseSIEFile } from '../sie-parser'
|
|
import { collectSIEDimensionUsage } from '../sie-dimensions'
|
|
import { normalizeLineDimensions } from '@/lib/bookkeeping/dimension-resolver'
|
|
import { generateSIEExport } from '@/lib/reports/sie-export'
|
|
|
|
// ============================================================
|
|
// Dimensions plan PR5: the lossless round-trip guarantee.
|
|
//
|
|
// parse(source) → what import writes (registry rows + line dimension maps)
|
|
// → generateSIEExport over exactly that state → parse(exported) must carry
|
|
// the same dimension surface: #DIM/#UNDERDIM declarations, #OBJEKT values,
|
|
// and per-line object lists. The comparison is structural (both files run
|
|
// through the same parser), so formatting/order differences don't matter.
|
|
// ============================================================
|
|
|
|
// Sequential-queue supabase mock: same consumption order as
|
|
// sie-export.test.ts documents:
|
|
// 0 fiscal_periods.single, 1 prev period, 2 accounts, 3 journal_entries,
|
|
// 4 journal_entry_lines, 5 dimensions, 6 dimension_values, 7 OB fallback
|
|
let resultIdx: number
|
|
let results: Array<{ data?: unknown; error?: unknown }>
|
|
|
|
function makeBuilder() {
|
|
const b: Record<string, unknown> = {}
|
|
for (const m of ['select', 'in', 'order', 'range', 'lt', 'lte', 'gte', 'gt', 'limit', 'neq', 'eq']) {
|
|
b[m] = vi.fn().mockReturnValue(b)
|
|
}
|
|
b.single = vi.fn().mockImplementation(async () => results[resultIdx++] ?? { data: null, error: null })
|
|
b.then = (resolve: (v: unknown) => void) => resolve(results[resultIdx++] ?? { data: null, error: null })
|
|
return b
|
|
}
|
|
|
|
function makeClient() {
|
|
return {
|
|
from: vi.fn().mockImplementation(() => makeBuilder()),
|
|
rpc: vi.fn().mockImplementation(async () => results[resultIdx++] ?? { data: null, error: null }),
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
} as any
|
|
}
|
|
|
|
beforeEach(() => {
|
|
resultIdx = 0
|
|
results = []
|
|
})
|
|
|
|
const SOURCE_SIE = [
|
|
'#FLAGGA 0',
|
|
'#SIETYP 4',
|
|
'#FNAMN "Roundtrip AB"',
|
|
'#RAR 0 20260101 20261231',
|
|
'#KONTO 5010 "Lokalhyra"',
|
|
'#KONTO 1930 "Företagskonto"',
|
|
'#DIM 1 "Kostnadsställe"',
|
|
'#DIM 6 "Projekt"',
|
|
'#UNDERDIM 2 "Kostnadsbärare" 1',
|
|
// Custom dimension + custom child — exactly what PR10's "Ny dimension"
|
|
// (SIE 20+, optional parent) produces. Proves the round-trip covers
|
|
// user-created dims, not just the reserved 1/2/6 set.
|
|
'#DIM 20 "Avdelning"',
|
|
'#UNDERDIM 25 "Team" 20',
|
|
'#OBJEKT 1 "KS01" "Butiken"',
|
|
'#OBJEKT 2 "KB1" "Bärare ett"',
|
|
'#OBJEKT 6 "P001" "Villa Almgren"',
|
|
'#OBJEKT 20 "SYD" "Avdelning Syd"',
|
|
'#OBJEKT 25 "T1" "Team ett"',
|
|
'#VER A 1 20260115 "Hyra januari"',
|
|
'{',
|
|
'#TRANS 5010 {1 "KS01" 2 "KB1" 6 "P001"} 15000.00',
|
|
'#TRANS 1930 {} -15000.00',
|
|
'}',
|
|
'#VER A 2 20260116 "Odeklarerat projekt"',
|
|
'{',
|
|
// P002 is referenced but never declared via #OBJEKT: import synthesizes
|
|
// it (name = code) and export must re-declare it.
|
|
'#TRANS 5010 {6 "P002"} 500.00',
|
|
'#TRANS 1930 {} -500.00',
|
|
'}',
|
|
'#VER A 3 20260117 "Avdelningskostnad"',
|
|
'{',
|
|
'#TRANS 5010 {20 "SYD" 25 "T1"} 800.00',
|
|
'#TRANS 1930 {} -800.00',
|
|
'}',
|
|
].join('\n')
|
|
|
|
describe('SIE dimensions round-trip', () => {
|
|
it('parse → import state → export → parse preserves the dimension surface', async () => {
|
|
const parsedSource = parseSIEFile(SOURCE_SIE)
|
|
expect(parsedSource.issues.filter((i) => i.severity === 'error')).toEqual([])
|
|
|
|
// ── What the importer writes ─────────────────────────────────
|
|
const usage = collectSIEDimensionUsage(parsedSource)
|
|
|
|
// Registry rows exactly as importDimensionRegistry inserts them.
|
|
const dimensionRows = [...usage.dims.entries()].map(([sieDimNo, info], i) => ({
|
|
id: `dim-${sieDimNo}`,
|
|
sie_dim_no: sieDimNo,
|
|
parent_sie_dim_no: info.parent ?? null,
|
|
name: info.name,
|
|
sort_order: i,
|
|
}))
|
|
const valueRows = [...usage.values.values()].map((v) => ({
|
|
dimension_id: `dim-${v.sieDimNo}`,
|
|
code: v.code,
|
|
name: v.name,
|
|
}))
|
|
|
|
// Journal lines exactly as importVouchers inserts them (dimensions jsonb
|
|
// via normalizeLineDimensions; SIE amount sign → debit/credit).
|
|
const journalEntries = parsedSource.vouchers.map((v, i) => ({
|
|
id: `e${i + 1}`,
|
|
entry_date: '2026-01-15',
|
|
voucher_number: v.number,
|
|
voucher_series: v.series,
|
|
description: v.description,
|
|
status: 'posted',
|
|
}))
|
|
const journalLines = parsedSource.vouchers.flatMap((v, i) =>
|
|
v.lines.map((line) => ({
|
|
journal_entry_id: `e${i + 1}`,
|
|
account_number: line.account,
|
|
debit_amount: line.amount > 0 ? line.amount : 0,
|
|
credit_amount: line.amount < 0 ? Math.abs(line.amount) : 0,
|
|
line_description: line.description ?? null,
|
|
dimensions: normalizeLineDimensions({ dimensions: line.dimensions ?? null }),
|
|
}))
|
|
)
|
|
|
|
// ── Export over exactly that state ───────────────────────────
|
|
results = [
|
|
{ data: { id: 'period-1', period_start: '2026-01-01', period_end: '2026-12-31' }, error: null },
|
|
{ data: null, error: null }, // prev period
|
|
{
|
|
data: [
|
|
{ account_number: '5010', account_name: 'Lokalhyra', sru_code: null, is_active: true },
|
|
{ account_number: '1930', account_name: 'Företagskonto', sru_code: null, is_active: true },
|
|
],
|
|
error: null,
|
|
},
|
|
{ data: journalEntries, error: null },
|
|
{ data: journalLines, error: null },
|
|
{ data: dimensionRows, error: null },
|
|
{ data: valueRows, error: null },
|
|
{ data: [], error: null }, // OB fallback
|
|
]
|
|
|
|
const exported = await generateSIEExport(makeClient(), 'company-1', {
|
|
fiscal_period_id: 'period-1',
|
|
company_name: 'Roundtrip AB',
|
|
org_number: null,
|
|
program_name: 'ERPBase',
|
|
})
|
|
|
|
// ── Compare the two dimension surfaces structurally ──────────
|
|
const parsedExport = parseSIEFile(exported)
|
|
expect(parsedExport.issues.filter((i) => i.severity === 'error')).toEqual([])
|
|
|
|
const dimSet = (dims: typeof parsedSource.dimensions) =>
|
|
new Set(dims.map((d) => `${d.sieDimNo}|${d.name}|${d.parentSieDimNo ?? ''}`))
|
|
// Every declaration that carries data survives: including the
|
|
// #UNDERDIM child with its parent link. (A declared dimension with no
|
|
// values and no tagged lines is metadata without data; export
|
|
// deliberately omits it, so the fixture gives every dim a value.)
|
|
for (const entry of dimSet(parsedSource.dimensions)) {
|
|
expect(dimSet(parsedExport.dimensions)).toContain(entry)
|
|
}
|
|
|
|
const valueSet = (values: typeof parsedSource.dimensionValues) =>
|
|
new Set(values.map((v) => `${v.sieDimNo}|${v.code}|${v.name}`))
|
|
for (const entry of valueSet(parsedSource.dimensionValues)) {
|
|
expect(valueSet(parsedExport.dimensionValues)).toContain(entry)
|
|
}
|
|
// The undeclared-but-referenced P002 got synthesized (name = code).
|
|
expect(valueSet(parsedExport.dimensionValues)).toContain('6|P002|P002')
|
|
|
|
// Per-line object lists survive verbatim.
|
|
const lineDims = (parsed: typeof parsedSource) =>
|
|
parsed.vouchers.flatMap((v) => v.lines.map((l) => l.dimensions ?? null))
|
|
expect(lineDims(parsedExport)).toEqual(lineDims(parsedSource))
|
|
})
|
|
|
|
it('collectSIEDimensionUsage synthesizes reserved names and prefers declared ones', () => {
|
|
const parsed = parseSIEFile(
|
|
[
|
|
'#FLAGGA 0',
|
|
'#SIETYP 4',
|
|
'#RAR 0 20260101 20261231',
|
|
// Dim 7 (reserved: Anställd) referenced without declaration; dim 6
|
|
// declared with a custom name that must win over "Projekt".
|
|
'#DIM 6 "Mina projekt"',
|
|
'#VER A 1 20260115 "Löner"',
|
|
'{',
|
|
'#TRANS 7010 {7 "ANNA" 6 "P001" 42 "X"} 1000.00',
|
|
'#TRANS 1930 {} -1000.00',
|
|
'}',
|
|
].join('\n')
|
|
)
|
|
|
|
const usage = collectSIEDimensionUsage(parsed)
|
|
expect(usage.dims.get(7)?.name).toBe('Anställd')
|
|
expect(usage.dims.get(6)?.name).toBe('Mina projekt')
|
|
// Unknown custom number falls back to a generic label.
|
|
expect(usage.dims.get(42)?.name).toBe('Dimension 42')
|
|
expect(usage.taggedLines).toBe(1)
|
|
expect([...usage.values.keys()].sort()).toEqual(['42 X', '6 P001', '7 ANNA'])
|
|
})
|
|
|
|
it('rejects registry codes that violate the DB CHECK but keeps them on lines', () => {
|
|
const longCode = 'X'.repeat(41)
|
|
const parsed = parseSIEFile(
|
|
[
|
|
'#FLAGGA 0',
|
|
'#SIETYP 4',
|
|
'#RAR 0 20260101 20261231',
|
|
'#VER A 1 20260115 "För lång kod"',
|
|
'{',
|
|
`#TRANS 5010 {6 "${longCode}"} 100.00`,
|
|
'#TRANS 1930 {} -100.00',
|
|
'}',
|
|
].join('\n')
|
|
)
|
|
|
|
// The line keeps the tag (legacy free-text survives on lines)…
|
|
expect(parsed.vouchers[0].lines[0].dimensions).toEqual({ '6': longCode })
|
|
// …but the registry collection skips it and reports why.
|
|
const usage = collectSIEDimensionUsage(parsed)
|
|
expect(usage.values.size).toBe(0)
|
|
expect(usage.invalidCodes.size).toBe(1)
|
|
})
|
|
})
|