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>
386 lines
12 KiB
TypeScript
386 lines
12 KiB
TypeScript
import { describe, it, expect } from 'vitest'
|
||
import {
|
||
anyAssetHasComponents,
|
||
buildEquityChangesNote,
|
||
buildK3RedovisningsPrinciper,
|
||
buildMateriellaAnlaggningsNot,
|
||
buildUppskjutenSkattNot,
|
||
} from '../k3-noter-builder'
|
||
|
||
describe('buildK3RedovisningsPrinciper', () => {
|
||
it('always includes the K3 framework citation and the standard policy paragraphs', () => {
|
||
const note = buildK3RedovisningsPrinciper(false)
|
||
expect(note.number).toBe(1)
|
||
expect(note.title).toBe('Redovisnings- och värderingsprinciper')
|
||
expect(note.body).toContain('BFNAR 2012:1')
|
||
expect(note.body).toContain('Uppskjuten skatt')
|
||
expect(note.body).toContain('Intäktsredovisning')
|
||
expect(note.body).toContain('Leasing')
|
||
expect(note.body).toContain('Finansiella instrument')
|
||
})
|
||
|
||
it('OMITS the komponentavskrivning paragraph when no asset has components', () => {
|
||
const note = buildK3RedovisningsPrinciper(false)
|
||
expect(note.body).not.toMatch(/Komponentavskrivning/)
|
||
})
|
||
|
||
it('INCLUDES the komponentavskrivning paragraph when an asset has components', () => {
|
||
const note = buildK3RedovisningsPrinciper(true)
|
||
expect(note.body).toMatch(/Komponentavskrivning/)
|
||
expect(note.body).toMatch(/betydande komponenter/)
|
||
})
|
||
})
|
||
|
||
describe('buildUppskjutenSkattNot', () => {
|
||
it('renders opening + change + closing line in the body', () => {
|
||
const note = buildUppskjutenSkattNot({
|
||
noteNumber: 4,
|
||
latentTaxOpening: 50_000,
|
||
latentTaxChange: 20_600,
|
||
latentTaxClosing: 70_600,
|
||
})
|
||
expect(note.number).toBe(4)
|
||
expect(note.title).toBe('Uppskjutna skatter')
|
||
// sv-SE thousand separator uses non-breaking space (\u00A0)
|
||
expect(note.body).toMatch(/Ingående saldo.*50/)
|
||
expect(note.body).toMatch(/Årets förändring.*20/)
|
||
expect(note.body).toMatch(/Utgående saldo.*70/)
|
||
})
|
||
|
||
it('handles zero values without producing NaN strings', () => {
|
||
const note = buildUppskjutenSkattNot({
|
||
noteNumber: 2,
|
||
latentTaxOpening: 0,
|
||
latentTaxChange: 0,
|
||
latentTaxClosing: 0,
|
||
})
|
||
expect(note.body).not.toMatch(/NaN/)
|
||
expect(note.body).toMatch(/0 kr/)
|
||
})
|
||
|
||
it('handles a negative change (återföring)', () => {
|
||
const note = buildUppskjutenSkattNot({
|
||
noteNumber: 3,
|
||
latentTaxOpening: 70_600,
|
||
latentTaxChange: -10_000,
|
||
latentTaxClosing: 60_600,
|
||
})
|
||
// Swedish locale formats negative numbers with the Unicode minus sign
|
||
// (U+2212), not the ASCII hyphen-minus: match either to be robust.
|
||
expect(note.body).toMatch(/[-−]10/)
|
||
expect(note.body).toMatch(/Ingående saldo.*70/)
|
||
expect(note.body).toMatch(/Utgående saldo.*60/)
|
||
})
|
||
|
||
it('mentions the 20.6% latent tax rate so readers understand the figures', () => {
|
||
const note = buildUppskjutenSkattNot({
|
||
noteNumber: 1,
|
||
latentTaxOpening: 0,
|
||
latentTaxChange: 0,
|
||
latentTaxClosing: 0,
|
||
})
|
||
expect(note.body).toMatch(/20,6/)
|
||
})
|
||
})
|
||
|
||
describe('buildEquityChangesNote', () => {
|
||
it('reconciles opening + changes to closing total', () => {
|
||
const result = buildEquityChangesNote({
|
||
opening: {
|
||
aktiekapital: 50_000,
|
||
bundna_reserver: 10_000,
|
||
balanserade_vinstmedel: 100_000,
|
||
},
|
||
changes: {
|
||
nyemission: 25_000,
|
||
utdelning: -15_000,
|
||
arets_resultat: 80_000,
|
||
},
|
||
})
|
||
// 50 000 + 10 000 + 100 000 + 25 000 - 15 000 + 80 000 = 250 000
|
||
expect(result.closing_total).toBe(250_000)
|
||
})
|
||
|
||
it('emits rows for each opening component plus changes plus closing', () => {
|
||
const result = buildEquityChangesNote({
|
||
opening: {
|
||
aktiekapital: 50_000,
|
||
bundna_reserver: 0,
|
||
balanserade_vinstmedel: 100_000,
|
||
},
|
||
changes: {
|
||
nyemission: 25_000,
|
||
utdelning: -15_000,
|
||
arets_resultat: 80_000,
|
||
},
|
||
})
|
||
const labels = result.rows.map((r) => r.label)
|
||
expect(labels).toContain('Ingående aktiekapital')
|
||
expect(labels).toContain('Ingående balanserade vinstmedel')
|
||
expect(labels).toContain('Nyemission')
|
||
expect(labels).toContain('Utdelning')
|
||
expect(labels).toContain('Årets resultat')
|
||
expect(labels).toContain('Summa utgående eget kapital')
|
||
})
|
||
|
||
it('OMITS the nyemission row when no nyemission happened (cleaner statement)', () => {
|
||
const result = buildEquityChangesNote({
|
||
opening: {
|
||
aktiekapital: 50_000,
|
||
bundna_reserver: 0,
|
||
balanserade_vinstmedel: 100_000,
|
||
},
|
||
changes: {
|
||
nyemission: 0,
|
||
utdelning: -15_000,
|
||
arets_resultat: 80_000,
|
||
},
|
||
})
|
||
const labels = result.rows.map((r) => r.label)
|
||
expect(labels).not.toContain('Nyemission')
|
||
// Utdelning + årets resultat still present
|
||
expect(labels).toContain('Utdelning')
|
||
expect(labels).toContain('Årets resultat')
|
||
})
|
||
|
||
it('OMITS the utdelning row when no utdelning happened', () => {
|
||
const result = buildEquityChangesNote({
|
||
opening: {
|
||
aktiekapital: 50_000,
|
||
bundna_reserver: 0,
|
||
balanserade_vinstmedel: 100_000,
|
||
},
|
||
changes: {
|
||
nyemission: 0,
|
||
utdelning: 0,
|
||
arets_resultat: 80_000,
|
||
},
|
||
})
|
||
const labels = result.rows.map((r) => r.label)
|
||
expect(labels).not.toContain('Nyemission')
|
||
expect(labels).not.toContain('Utdelning')
|
||
// Årets resultat always shown: even when zero: so the year-end
|
||
// disposition is visible in the statement.
|
||
expect(labels).toContain('Årets resultat')
|
||
})
|
||
|
||
it('handles all-zero opening + changes without crashing', () => {
|
||
const result = buildEquityChangesNote({
|
||
opening: { aktiekapital: 0, bundna_reserver: 0, balanserade_vinstmedel: 0 },
|
||
changes: { nyemission: 0, utdelning: 0, arets_resultat: 0 },
|
||
})
|
||
expect(result.closing_total).toBe(0)
|
||
expect(result.rows.length).toBeGreaterThan(0)
|
||
})
|
||
})
|
||
|
||
describe('buildMateriellaAnlaggningsNot', () => {
|
||
it('returns null when there are no tangible assets', () => {
|
||
const note = buildMateriellaAnlaggningsNot({
|
||
noteNumber: 5,
|
||
assets: [],
|
||
})
|
||
expect(note).toBeNull()
|
||
})
|
||
|
||
it('returns null when only immaterial assets exist (they belong in a separate note)', () => {
|
||
const note = buildMateriellaAnlaggningsNot({
|
||
noteNumber: 5,
|
||
assets: [
|
||
{
|
||
name: 'Software License',
|
||
category: 'immaterial',
|
||
acquisition_date: '2025-01-01',
|
||
acquisition_cost: 50_000,
|
||
k3_components: null,
|
||
disposed_at: null,
|
||
useful_life_months: 60,
|
||
},
|
||
],
|
||
})
|
||
expect(note).toBeNull()
|
||
})
|
||
|
||
it('emits the avskrivningstider summary for tangible assets without components', () => {
|
||
const note = buildMateriellaAnlaggningsNot({
|
||
noteNumber: 3,
|
||
assets: [
|
||
{
|
||
name: 'Macbook Pro',
|
||
category: 'computer',
|
||
acquisition_date: '2025-01-01',
|
||
acquisition_cost: 30_000,
|
||
k3_components: null,
|
||
disposed_at: null,
|
||
useful_life_months: 36, // 3 år
|
||
},
|
||
{
|
||
name: 'Skrivbord',
|
||
category: 'equipment',
|
||
acquisition_date: '2025-01-01',
|
||
acquisition_cost: 5_000,
|
||
k3_components: null,
|
||
disposed_at: null,
|
||
useful_life_months: 60, // 5 år
|
||
},
|
||
],
|
||
})
|
||
expect(note).not.toBeNull()
|
||
expect(note!.title).toBe('Materiella anläggningstillgångar')
|
||
expect(note!.body).toMatch(/Datorer: 3 år/)
|
||
expect(note!.body).toMatch(/Inventarier: 5 år/)
|
||
// Should NOT mention komponentuppdelning since no components were given
|
||
expect(note!.body).not.toMatch(/Komponentuppdelning/)
|
||
})
|
||
|
||
it('renders per-component sub-totals when an asset has K3 components', () => {
|
||
const note = buildMateriellaAnlaggningsNot({
|
||
noteNumber: 3,
|
||
assets: [
|
||
{
|
||
name: 'Lokalbyggnad Sjövägen 12',
|
||
category: 'building',
|
||
acquisition_date: '2020-01-01',
|
||
acquisition_cost: 5_000_000,
|
||
k3_components: [
|
||
{
|
||
name: 'Stomme',
|
||
acquisition_cost: 3_000_000,
|
||
accumulated_depreciation: 300_000,
|
||
useful_life_months: 600, // 50 år
|
||
},
|
||
{
|
||
name: 'Tak',
|
||
acquisition_cost: 800_000,
|
||
accumulated_depreciation: 200_000,
|
||
useful_life_months: 360, // 30 år
|
||
},
|
||
{
|
||
name: 'Installationer',
|
||
acquisition_cost: 1_200_000,
|
||
accumulated_depreciation: 600_000,
|
||
useful_life_months: 240, // 20 år
|
||
},
|
||
],
|
||
disposed_at: null,
|
||
useful_life_months: 600,
|
||
},
|
||
],
|
||
})
|
||
expect(note).not.toBeNull()
|
||
expect(note!.body).toMatch(/Komponentuppdelning/)
|
||
expect(note!.body).toMatch(/Stomme/)
|
||
expect(note!.body).toMatch(/Tak/)
|
||
expect(note!.body).toMatch(/Installationer/)
|
||
// Per-component totals must reconcile: 3,000,000 + 800,000 + 1,200,000 = 5,000,000
|
||
// Look for the summary line
|
||
expect(note!.body).toMatch(/Summa:/)
|
||
// Total acquisition cost text (with sv-SE separator)
|
||
// Just confirm 5 000 000 appears as a sub-total somewhere
|
||
expect(note!.body).toMatch(/5\s?000\s?000/)
|
||
})
|
||
|
||
it('skips disposed assets in the calculation', () => {
|
||
const note = buildMateriellaAnlaggningsNot({
|
||
noteNumber: 3,
|
||
assets: [
|
||
{
|
||
name: 'Old Macbook',
|
||
category: 'computer',
|
||
acquisition_date: '2020-01-01',
|
||
acquisition_cost: 20_000,
|
||
k3_components: null,
|
||
disposed_at: '2025-06-01',
|
||
useful_life_months: 36,
|
||
},
|
||
],
|
||
})
|
||
expect(note).toBeNull()
|
||
})
|
||
})
|
||
|
||
describe('anyAssetHasComponents', () => {
|
||
it('returns false for empty array', () => {
|
||
expect(anyAssetHasComponents([])).toBe(false)
|
||
})
|
||
|
||
it('returns false when no asset has a components array', () => {
|
||
expect(
|
||
anyAssetHasComponents([
|
||
{
|
||
name: 'X',
|
||
category: 'computer',
|
||
acquisition_date: '2025-01-01',
|
||
acquisition_cost: 1000,
|
||
k3_components: null,
|
||
disposed_at: null,
|
||
useful_life_months: 36,
|
||
},
|
||
]),
|
||
).toBe(false)
|
||
})
|
||
|
||
it('returns true when at least one asset has a valid components array', () => {
|
||
expect(
|
||
anyAssetHasComponents([
|
||
{
|
||
name: 'Y',
|
||
category: 'building',
|
||
acquisition_date: '2025-01-01',
|
||
acquisition_cost: 1_000_000,
|
||
k3_components: [
|
||
{
|
||
name: 'Stomme',
|
||
acquisition_cost: 800_000,
|
||
accumulated_depreciation: 0,
|
||
useful_life_months: 600,
|
||
},
|
||
],
|
||
disposed_at: null,
|
||
useful_life_months: 600,
|
||
},
|
||
]),
|
||
).toBe(true)
|
||
})
|
||
|
||
it('ignores disposed assets when checking for components', () => {
|
||
expect(
|
||
anyAssetHasComponents([
|
||
{
|
||
name: 'Sold Building',
|
||
category: 'building',
|
||
acquisition_date: '2020-01-01',
|
||
acquisition_cost: 1_000_000,
|
||
k3_components: [
|
||
{
|
||
name: 'Stomme',
|
||
acquisition_cost: 800_000,
|
||
accumulated_depreciation: 0,
|
||
useful_life_months: 600,
|
||
},
|
||
],
|
||
disposed_at: '2025-06-01',
|
||
useful_life_months: 600,
|
||
},
|
||
]),
|
||
).toBe(false)
|
||
})
|
||
|
||
it('rejects malformed component payloads (typeguard)', () => {
|
||
expect(
|
||
anyAssetHasComponents([
|
||
{
|
||
name: 'Bad data',
|
||
category: 'building',
|
||
acquisition_date: '2025-01-01',
|
||
acquisition_cost: 1_000_000,
|
||
// Missing required fields
|
||
k3_components: [{ name: 'Stomme' }],
|
||
disposed_at: null,
|
||
useful_life_months: 600,
|
||
},
|
||
]),
|
||
).toBe(false)
|
||
})
|
||
})
|