* refactor(arcim-migration): remove the dead gateway SIE export path fetchSIEExport and SIEExportFile have had zero callers since the direct provider clients replaced the Arcim Sync gateway (#181, #718). The path returned SIE as a pre-decoded string, and the gateway's decode of CP437 bytes as windows-1252 is what wrote the 2026-03-17 mojibake into posted entries. Deleting it makes the string-typed SIE fetch impossible to re-wire; a comment marks the grave. The consent lifecycle and entity accessors stay untouched. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(import): warn when SIE text carries CP437-as-CP1252 mojibake The 2026-03-17 migration wrote mojibake ("L"neutbetalning"-style C1 specials) into posted entries because the retired gateway handed the /import-sie handler an already-decoded string: byte-level encoding detection never saw it, and nothing downstream checked. The live bug is gone; this is the tripwire so the signature can never land silently again. - lib/import/sie-artifact-scan.ts: pure scanner over parsed SIE account names and voucher/line descriptions, reusing hasCp1252Artifact from charset-repair; flags at >= 2 hits so a lone legitimate curly quote or apostrophe cannot false-positive a whole file. - arcim-migration /import-sie: warn-never-block; the Swedish warning rides on result.warnings, which the workspace UI already renders, plus a server-side log.warn. - wizard parse route: same scan, surfaced through the existing parse-issue warnings card in the preview, pointing at the first affected line. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * test(bookkeeping): pin the reported gateway mojibake strings Adds the four strings reported from the affected company's journal as reverse_cp437 cases (all reverse losslessly) plus a false-positive guard: space-padded typography must never route into the CP437 reversal. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
210 lines
9.6 KiB
TypeScript
210 lines
9.6 KiB
TypeScript
import { describe, it, expect } from 'vitest'
|
||
import {
|
||
deaccent,
|
||
reverseMojibake,
|
||
reverseCp437Mojibake,
|
||
hasLostByte,
|
||
hasMojibakeSignature,
|
||
hasCp1252Artifact,
|
||
isClean,
|
||
resolveCorrectName,
|
||
REPLACEMENT_CHAR,
|
||
} from '../charset-repair'
|
||
|
||
describe('deaccent', () => {
|
||
it('strips Swedish diacritics, preserving case and other chars', () => {
|
||
expect(deaccent('Utgående moms försäljning inom Sverige, 25%')).toBe(
|
||
'Utgaende moms forsaljning inom Sverige, 25%',
|
||
)
|
||
expect(deaccent('Övriga bankkonton')).toBe('Ovriga bankkonton')
|
||
expect(deaccent('Årets resultat')).toBe('Arets resultat')
|
||
expect(deaccent('Löner')).toBe('Loner')
|
||
expect(deaccent('HEMKÖP LINNÉ')).toBe('HEMKOP LINNE')
|
||
})
|
||
it('is a no-op on already-ASCII names', () => {
|
||
expect(deaccent('Kassa')).toBe('Kassa')
|
||
})
|
||
})
|
||
|
||
describe('reverseMojibake (double-encoding)', () => {
|
||
// Real prod fixtures (chart_of_accounts, project pwxtzglxptnnvjrpixpg).
|
||
it('recovers lowercase å/ä/ö', () => {
|
||
expect(reverseMojibake('Företagskonto / checkkonto')).toBe('Företagskonto / checkkonto')
|
||
expect(reverseMojibake('Leverantörsskulder')).toBe('Leverantörsskulder')
|
||
expect(reverseMojibake('Utgående moms försäljning inom Sverige, 25%')).toBe(
|
||
'Utgående moms försäljning inom Sverige, 25%',
|
||
)
|
||
})
|
||
it('recovers UPPERCASE Å/Ä/Ö (CP1252 punctuation continuation bytes)', () => {
|
||
expect(reverseMojibake('Övriga bankkonton')).toBe('Övriga bankkonton')
|
||
expect(reverseMojibake('Ã…rets resultat')).toBe('Årets resultat')
|
||
})
|
||
it('recovers custom (non-BAS) names losslessly', () => {
|
||
expect(reverseMojibake('Lån från närstående personer, långfristig del')).toBe(
|
||
'Lån från närstående personer, långfristig del',
|
||
)
|
||
})
|
||
it('returns null for already-correct names (no-op safety)', () => {
|
||
expect(reverseMojibake('Företagskonto')).toBeNull() // ö alone isn't valid double-encoding
|
||
expect(reverseMojibake('Kassa')).toBe('Kassa') // pure ASCII round-trips to itself
|
||
})
|
||
})
|
||
|
||
describe('reverseCp437Mojibake (CP437 read as CP1252)', () => {
|
||
// Real prod fixtures: CP437 SIE diacritic bytes rendered as CP1252 specials.
|
||
it('recovers ö/ä/å/Ö from C1 specials', () => {
|
||
expect(reverseCp437Mojibake('F”rmedlad frakt')).toBe('Förmedlad frakt') // ö (0x94→”)
|
||
expect(reverseCp437Mojibake('™vriga fastighetskostnader, ej avdragsgilla')).toBe(
|
||
'Övriga fastighetskostnader, ej avdragsgilla', // Ö (0x99→™)
|
||
)
|
||
expect(reverseCp437Mojibake('V„rdef”r„ndring kapitalf”rs„kring')).toBe(
|
||
'Värdeförändring kapitalförsäkring', // ä (0x84→„), ö (0x94→”)
|
||
)
|
||
expect(reverseCp437Mojibake('p† arvoden')).toBe('på arvoden') // å (0x86→†)
|
||
expect(reverseCp437Mojibake('L”ner till tj„nstem„n (avgiftsbefriade)')).toBe(
|
||
'Löner till tjänstemän (avgiftsbefriade)',
|
||
)
|
||
})
|
||
it('returns null on clean names and on bytes it cannot safely map', () => {
|
||
expect(reverseCp437Mojibake('Kassa')).toBeNull()
|
||
expect(reverseCp437Mojibake('Företagskonto')).toBeNull() // ö (0xF6→÷) not a CP437 letter byte
|
||
})
|
||
|
||
it('recovers the 2026-03-17 gateway-migration strings (reported prod fixtures)', () => {
|
||
// The retired Arcim Sync gateway decoded Bokio's CP437 SIE bytes as
|
||
// windows-1252 before JSON-encoding them: ö 0x94 → U+201D ”, ä 0x84 →
|
||
// U+201E „, Ä 0x8E → U+017D Ž. These four strings are the ones reported
|
||
// from the affected company's journal; all reverse losslessly.
|
||
expect(reverseCp437Mojibake('Ink”p tj„nster inom EU')).toBe('Inköp tjänster inom EU')
|
||
expect(reverseCp437Mojibake('L”neutbetalning')).toBe('Löneutbetalning')
|
||
expect(reverseCp437Mojibake('BANKTJŽNSTER')).toBe('BANKTJÄNSTER')
|
||
expect(reverseCp437Mojibake('UTLŽGG')).toBe('UTLÄGG')
|
||
})
|
||
|
||
it('never routes legitimate space-padded typography into the CP437 reversal (false-positive guard)', () => {
|
||
// A curly quote or ellipsis that is NOT letter-adjacent is punctuation,
|
||
// not a mangled diacritic: the artifact detector must stay quiet so
|
||
// resolveCorrectName leaves the text untouched.
|
||
expect(hasCp1252Artifact('Betalning enligt avtal ” 2024')).toBe(false)
|
||
expect(resolveCorrectName('Betalning enligt avtal ” 2024', [])).toBeNull()
|
||
expect(hasCp1252Artifact('Avvaktar underlag …')).toBe(false)
|
||
expect(resolveCorrectName('Avvaktar underlag …', [])).toBeNull()
|
||
})
|
||
})
|
||
|
||
describe('resolveCorrectName: CP437 branch', () => {
|
||
it('reverses a CP437-as-CP1252 name without a sibling', () => {
|
||
expect(resolveCorrectName('™vriga fastighetskostnader', [])).toEqual({
|
||
corrected: 'Övriga fastighetskostnader',
|
||
method: 'reverse_cp437',
|
||
})
|
||
})
|
||
})
|
||
|
||
describe('signature detectors', () => {
|
||
it('detects lost-byte and mojibake', () => {
|
||
expect(hasLostByte('Ackumulerade nedskrivningar p' + REPLACEMENT_CHAR + ' bilar')).toBe(true)
|
||
expect(hasLostByte('Kassa')).toBe(false)
|
||
expect(hasMojibakeSignature('Företagskonto')).toBe(true)
|
||
expect(hasMojibakeSignature('Företagskonto')).toBe(false)
|
||
})
|
||
})
|
||
|
||
describe('resolveCorrectName', () => {
|
||
it('restores a stripped name from a de-accent-equal diacritic-bearing sibling', () => {
|
||
const r = resolveCorrectName('Utgaende moms forsaljning inom Sverige, 25%', [
|
||
'Utgående moms försäljning inom Sverige, 25%', // seed wording (clean sibling)
|
||
'Utgående moms på försäljning inom Sverige, 25 %', // BAS wording (different, won't match)
|
||
])
|
||
expect(r).toEqual({
|
||
corrected: 'Utgående moms försäljning inom Sverige, 25%',
|
||
method: 'sibling_stripped',
|
||
})
|
||
})
|
||
|
||
it('restores a lost-byte name by wildcard-matching one sibling', () => {
|
||
const r = resolveCorrectName(`Ackumulerade nedskrivningar p${REPLACEMENT_CHAR} bilar`, [
|
||
'Ackumulerade nedskrivningar på bilar',
|
||
'Ackumulerade avskrivningar på bilar', // same length but different word: must NOT match
|
||
])
|
||
expect(r).toEqual({
|
||
corrected: 'Ackumulerade nedskrivningar på bilar',
|
||
method: 'sibling_lostbyte',
|
||
})
|
||
})
|
||
|
||
it('reverses a double-encoded name without needing a sibling', () => {
|
||
const r = resolveCorrectName('Ã…rets resultat', [])
|
||
expect(r).toEqual({ corrected: 'Årets resultat', method: 'reverse' })
|
||
})
|
||
|
||
it('leaves a clean name untouched (returns null)', () => {
|
||
expect(resolveCorrectName('Företagskonto', ['Företagskonto'])).toBeNull()
|
||
expect(resolveCorrectName('Kassa', [])).toBeNull()
|
||
})
|
||
|
||
it('refuses to guess when a stripped name has no diacritic-bearing sibling', () => {
|
||
// Only stripped / identical siblings → no confident canonical → skip.
|
||
expect(resolveCorrectName('Forsaljning webshop', ['Forsaljning webshop'])).toBeNull()
|
||
})
|
||
|
||
it('refuses to guess a lost-byte name when two distinct siblings match', () => {
|
||
const r = resolveCorrectName(`Utg${REPLACEMENT_CHAR}ende`, ['Utgående', 'Utgaende'])
|
||
// 'Utgaende' (no diacritic) also matches the wildcard → ambiguous → null.
|
||
expect(r).toBeNull()
|
||
})
|
||
|
||
it('ignores corrupt candidates when choosing a canonical', () => {
|
||
const r = resolveCorrectName('Leverantorsskulder', [
|
||
'Leverantörsskulder', // mojibake candidate: ignored
|
||
'Leverantörsskulder', // the clean one
|
||
])
|
||
expect(r?.corrected).toBe('Leverantörsskulder')
|
||
})
|
||
|
||
it('NEVER strips a correct name down to a de-accented sibling (directional guard)', () => {
|
||
// The dry-run bug: a correct "Försäljning varor 25%" must not be rewritten to
|
||
// a partial-stripped "Försaljning varor 25%" sibling. The input carries
|
||
// diacritics, so the stripped branch must not fire.
|
||
expect(
|
||
resolveCorrectName('Försäljning varor 25%', ['Försaljning varor 25%']),
|
||
).toBeNull()
|
||
expect(resolveCorrectName('Ränteintäkter', ['Ränteintakter'])).toBeNull()
|
||
})
|
||
|
||
it('does not pick a CP437-as-CP1252 sibling for a lost-byte name', () => {
|
||
// The dry-run bug: "F�rmedlad frakt" wildcard-matched "F”rmedlad frakt"
|
||
// (itself a different mojibake). Only the genuinely clean sibling wins.
|
||
const r = resolveCorrectName(`F${REPLACEMENT_CHAR}rmedlad frakt`, [
|
||
'F”rmedlad frakt', // CP1252 artifact: not clean, must be ignored
|
||
'Förmedlad frakt', // the clean one
|
||
])
|
||
expect(r).toEqual({ corrected: 'Förmedlad frakt', method: 'sibling_lostbyte' })
|
||
})
|
||
|
||
it('classifies CP1252 artifacts as not clean', () => {
|
||
expect(hasCp1252Artifact('F”rmedlad frakt')).toBe(true)
|
||
expect(hasCp1252Artifact('™vriga fastighetskostnader')).toBe(true) // Ö→™ at word start
|
||
expect(hasCp1252Artifact('Förmedlad frakt')).toBe(false)
|
||
expect(isClean('Förmedlad frakt')).toBe(true)
|
||
expect(isClean('F”rmedlad frakt')).toBe(false)
|
||
expect(isClean(`F${REPLACEMENT_CHAR}rmedlad`)).toBe(false)
|
||
expect(isClean('Företagskonto')).toBe(false)
|
||
})
|
||
|
||
it('does NOT flag a legitimate space-padded en-dash as corrupt', () => {
|
||
// Real BAS names: "Kundfordringar – delad faktura" (1513), "Periodiseringsfond
|
||
// 2021 – nr 2". The en-dash is space-padded punctuation, not a mangled letter.
|
||
expect(hasCp1252Artifact('Kundfordringar – delad faktura')).toBe(false)
|
||
expect(hasCp1252Artifact('Periodiseringsfond 2021 – nr 2')).toBe(false)
|
||
expect(isClean('Kundfordringar – delad faktura')).toBe(true)
|
||
// …so it can serve as the repair target for its lost-byte sibling (the
|
||
// en-dash byte 0x96 itself becomes U+FFFD when a CP1252 file is read as UTF-8).
|
||
expect(
|
||
resolveCorrectName(`Kundfordringar ${REPLACEMENT_CHAR} delad faktura`, [
|
||
'Kundfordringar – delad faktura',
|
||
]),
|
||
).toEqual({ corrected: 'Kundfordringar – delad faktura', method: 'sibling_lostbyte' })
|
||
})
|
||
})
|