File size: 3,121 Bytes
bfd6783
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
import io

from finbot.extract import extract, extract_csv, extract_xlsx, rows_to_text

CSV_BYTES = (
    b"Date,Narration,Withdrawal,Deposit\n"
    b"01/08/2026,SWIGGY BANGALORE,450.00,\n"
    b"03/08/2026,SALARY AUG,,85000.00\n"
    b"05/08/2026,NETFLIX.COM,649.00,\n"
)


def make_xlsx() -> bytes:
    from openpyxl import Workbook

    workbook = Workbook()
    sheet = workbook.active
    sheet.append(["Date", "Description", "Amount"])
    sheet.append(["2026-08-01", "Swiggy", 450])
    sheet.append([None, None, None])  # blank row must be dropped
    sheet.append(["2026-08-05", "Netflix", 649])
    buffer = io.BytesIO()
    workbook.save(buffer)
    return buffer.getvalue()


class TestCSV:
    def test_parses_rows(self):
        doc = extract_csv(CSV_BYTES)
        assert doc.kind == "csv"
        assert len(doc.rows) == 4  # header + 3
        assert doc.rows[1][1] == "SWIGGY BANGALORE"

    def test_semicolon_delimiter(self):
        doc = extract_csv(b"a;b;c\n1;2;3\n4;5;6\n")
        assert doc.rows[0] == ["a", "b", "c"]

    def test_skips_blank_rows(self):
        doc = extract_csv(b"a,b\n\n1,2\n")
        assert len(doc.rows) == 2

    def test_handles_bom(self):
        doc = extract_csv(b"\xef\xbb\xbfDate,Amount\n2026-08-01,10\n")
        assert doc.rows[0][0] == "Date"

    def test_handles_non_utf8(self):
        doc = extract_csv("Caf\xe9,10\n".encode("latin-1"))
        assert "Caf" in doc.rows[0][0]

    def test_single_column_does_not_crash(self):
        doc = extract_csv(b"justonecolumn\nvalue\n")
        assert len(doc.rows) == 2


class TestXLSX:
    def test_parses_rows(self):
        doc = extract_xlsx(make_xlsx())
        assert doc.kind == "xlsx"
        assert doc.rows[0] == ["Date", "Description", "Amount"]
        assert ["2026-08-05", "Netflix", "649"] in doc.rows

    def test_drops_blank_rows(self):
        doc = extract_xlsx(make_xlsx())
        assert all(any(cell for cell in row) for row in doc.rows)


class TestDispatch:
    def test_by_extension(self):
        assert extract(CSV_BYTES, "statement.csv").kind == "csv"
        assert extract(make_xlsx(), "book.xlsx").kind == "xlsx"

    def test_by_mime_when_no_extension(self):
        assert extract(CSV_BYTES, "", "text/csv").kind == "csv"

    def test_plain_text(self):
        doc = extract(b"hello", "notes.txt")
        assert doc.kind == "text"
        assert doc.text == "hello"

    def test_legacy_xls_is_reported_not_crashed(self):
        doc = extract(b"\xd0\xcf\x11\xe0", "old.xls")
        assert doc.kind == "unsupported"
        assert ".xlsx" in doc.note

    def test_unknown_type_explains_itself(self):
        doc = extract(b"\x00\x01", "thing.bin")
        assert doc.kind == "unsupported"
        assert doc.note

    def test_is_empty(self):
        assert extract(b"", "empty.txt").is_empty


class TestRowsToText:
    def test_pipe_delimited(self):
        text = rows_to_text([["a", "b"], ["1", "2"]])
        assert text == "a | b\n1 | 2"

    def test_respects_limit(self):
        text = rows_to_text([["x"]] * 100, limit=3)
        assert len(text.splitlines()) == 3