Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1c66773ff4 | ||
|
|
e1bdb6c703 | ||
|
|
777a3b56e2 | ||
|
|
d5286c3600 | ||
|
|
f83d1bd6f8 | ||
|
|
6c0d675c5d | ||
|
|
d2c4343136 |
@@ -7,6 +7,23 @@ folgen [Semantic Versioning](https://semver.org/lang/de/). Die Version steht in
|
||||
|
||||
## [Unveröffentlicht]
|
||||
|
||||
## [0.6.0] - 2026-10-11
|
||||
|
||||
### Hinzugefügt
|
||||
- Steuertabellen 2026 für `tax`: `demo/taxDE2026`, `taxAT2026`, `taxCH2026` (nur Bundessteuer), `taxLI2026` (nur Landessteuer), erzeugt mit `tools/gen_tax_tables.rb`, mit Tests.
|
||||
|
||||
### Geändert
|
||||
- `record`: 0 und 1 sind die erste Zeile, negative Zahlen zählen vom Ende (`-1` letzte, `-2` vorletzte ...). Außerhalb der Tabelle kommt eine leere Tabelle, Text und Dezimalzahlen sind ein Fehler. (Vorher lieferten 0, -1 und Text ungewollt Zeilen vom Ende.) `testall.cmds` und `man/record.man` zeigen es.
|
||||
- README und `man/row.man` erklären, warum `select` in bash/zsh/ksh einen Syntaxfehler gibt und wie man es trotzdem aufruft.
|
||||
|
||||
### Behoben
|
||||
- `rdb man` zeigte `\\select` in den Man-Pages als `\elect`: ein doppelter Backslash wird jetzt zuerst geschützt.
|
||||
|
||||
## [0.5.0] - 2026-10-11
|
||||
|
||||
### Hinzugefügt
|
||||
- `pick`: Tabelle auf dem Terminal durchblättern, filtern und eine oder mehrere Zeilen (`-m`) auswählen; die Auswahl geht als Tabelle auf die Standardausgabe. `-k` nimmt die Tasten aus einem String. Dazu `man/pick.man`, Beispiele in `testall.cmds` und Tests (auch mit Pseudo-Terminal).
|
||||
|
||||
## [0.4.0] - 2026-10-11
|
||||
|
||||
### Hinzugefügt
|
||||
|
||||
@@ -102,6 +102,45 @@ In der Vorlage: `<Spalte>` oder `<Spalte:20>` ist ein Feld (mit Breite), `[ Spei
|
||||
`[ Abbrechen ]` (oder Cancel, Abort, Quit) bricht ab, `<!date!>` fügt Befehlsausgabe ein. Kommt eine Spalte mehrfach
|
||||
vor, ist es ein Feld. Tab/Pfeile wechseln, Enter bestätigt, Esc bricht ab (Status 1, keine Ausgabe).
|
||||
|
||||
## Zeilen auswählen: `pick`
|
||||
|
||||
`pick` zeigt eine Tabelle auf dem Terminal, man scrollt, filtert und wählt eine oder mehrere Zeilen. Die Tabelle
|
||||
kommt von der Standardeingabe, die gewählten Zeilen gehen als Tabelle auf die Standardausgabe, also mitten in der Pipe:
|
||||
|
||||
```
|
||||
pick < inventory eine Zeile wählen
|
||||
pick -m < inventory | total Amount Value mehrere (Leertaste markiert, a alle)
|
||||
column Item Description < inventory | pick -m | jointable - owners
|
||||
pick -m -k 'down space down enter' < inventory Tasten aus einem String (Skripte, Tests)
|
||||
```
|
||||
|
||||
Pfeile, Bild auf/ab, Pos1/Ende scrollen; `/` filtert (Enter behält den Filter, Esc löscht ihn); Enter wählt;
|
||||
Esc bricht ab (Status 1, keine Ausgabe). Ohne Markierung gilt die Zeile unter dem Cursor.
|
||||
|
||||
## Steuertabellen 2026 für `tax`
|
||||
|
||||
`demo/` enthält vier Tarife für das Steuerjahr 2026 im Format von `tax` (income, tax, percent), jeweils für
|
||||
Alleinstehende (Grundtarif). Erzeugt mit `ruby tools/gen_tax_tables.rb`.
|
||||
|
||||
| Datei | Land | Quelle | Genauigkeit |
|
||||
|---|---|---|---|
|
||||
| `taxDE2026` | Deutschland, Einkommensteuer | § 32a EStG (Grundfreibetrag 12 348 €) | Kurvenstücke durch Geraden genähert, Abweichung unter 0,15 € (die Formel rundet ab, `tax` auf) |
|
||||
| `taxAT2026` | Österreich, Einkommensteuer | § 33 EStG (0 % bis 13 539 €, dann 20/30/40/48/50/55 %) | exakt, vor Absetzbeträgen |
|
||||
| `taxCH2026` | Schweiz, **nur direkte Bundessteuer** | Art. 36 DBG, ESTV-Rundschreiben 2-215-D-2025 | Stufen exakt, das Gesetz rechnet in 100-Franken-Schritten |
|
||||
| `taxLI2026` | Liechtenstein, **nur Landessteuer** | Art. 19 SteG (LR 640.0, Fassung 01.07.2026) | exakt |
|
||||
|
||||
```
|
||||
tax 60000 < demo/taxDE2026 14233
|
||||
tax 60000 < demo/taxAT2026 15447
|
||||
tax 60000 < demo/taxCH2026 671
|
||||
tax 60000 < demo/taxLI2026 1396 (mal 1 + Gemeindezuschlag, 150 % bis 250 %)
|
||||
```
|
||||
|
||||
Der Betrag ist die Steuer auf das **zu versteuernde** Einkommen. Abzüge, Sozialversicherung, Absetzbeträge,
|
||||
Kirchensteuer und Soli sind nicht enthalten. Für die Schweiz kommen Kanton und Gemeinde dazu (sie sind meist
|
||||
höher als der Bund), für Liechtenstein der Gemeindezuschlag: gesamt = Landessteuer × (1 + Zuschlag/100).
|
||||
Ehepaar- und Alleinerziehendentarife fehlen. Das ist keine Steuerberatung.
|
||||
|
||||
## Befehle
|
||||
|
||||
| Gruppe | Befehle |
|
||||
@@ -116,7 +155,7 @@ vor, ist es ein Feld. Tab/Pfeile wechseln, Enter bestätigt, Esc bricht ab (Stat
|
||||
| Text | `cap` `uppercase` `lowercase` `substitute` `trimblank` `padstring` `length` `filesize` `ascii` `chr` `word` `prompt` `not` `path` |
|
||||
| Umwandeln | `tabletolist` `listtotable` `listtosh` `tabletofix` `fixtotable` `tabletostruct` `tabletotbl` `tabletofact` `tabletorule` `tabletom4` `tabletosed` `translate` `uniondict` |
|
||||
| Datum, Namen | `julian` `gregorian` `computedate` `todaysdate` `soundex` `like` `hashkey` |
|
||||
| Berichte | `report` `reportwriter` `screen` `mask` `label` `letter` |
|
||||
| Berichte | `report` `reportwriter` `screen` `mask` `pick` `label` `letter` |
|
||||
| Terminal, Hilfe | `termput` `tset` `clear.rdb` `cursor` `menu` `rdb` `commands` `helpme` `whatis` `whatwill` `howmany` `man` |
|
||||
| Betrieb | `testall` `testsearch` `timesearch` `rmcore` `cpdir.rdb` |
|
||||
| Buchhaltung (/act) | `act` `chartdup` `foot` `adjust` `balance` `close` `consolidate` `getjournal` `fillform` `calculate` `tax` `cashflow` `calcpay` `w2` `bom` `explode` `sale` `invoice` `cstate` `update.inv` |
|
||||
@@ -160,6 +199,10 @@ er nicht mehr passt (außer mit `-a`). Beide halten die Indizes aktuell.
|
||||
`row`, `compute`, `column`, `sorttable`, `jointable`, `rename`, `validate` und
|
||||
`rdb` haben dazu den Abschnitt „Case of Column Names“; er ist die einzige
|
||||
Ergänzung gegenüber den Original-Seiten.
|
||||
- `select` ist in bash, zsh und ksh ein reserviertes Wort. `select 'Cost > 5' < inventory` endet dort
|
||||
mit `syntax error near unexpected token '<'`, weil die Shell das Schlüsselwort vor dem `PATH` auswertet.
|
||||
Es gibt dasselbe unter dem Namen `row`. Außerdem gehen `\select ...`, `command select ...` und
|
||||
`rdb select ...`.
|
||||
- `jointable` braucht keine vorsortierten Tabellen.
|
||||
- `ve` ist ein zeilenorientierter Editor, kein Bildschirmeditor.
|
||||
- `soundex` nutzt den Standard-Algorithmus. Die Beispielwerte im Handbuch
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
income tax percent
|
||||
------ --- -------
|
||||
0 0.00 0
|
||||
13539 0.00 20
|
||||
21992 1690.60 30
|
||||
36458 6030.40 40
|
||||
70365 19593.20 48
|
||||
104859 36150.32 50
|
||||
1000000 483720.82 55
|
||||
@@ -0,0 +1,14 @@
|
||||
income tax percent
|
||||
------ --- -------
|
||||
0 0.00 0
|
||||
15200 0.00 0.77
|
||||
33200 138.60 0.88
|
||||
43500 229.20 2.64
|
||||
58000 612.00 2.97
|
||||
76200 1152.50 5.94
|
||||
82100 1502.95 6.6
|
||||
108900 3271.75 8.8
|
||||
141500 6140.55 11
|
||||
185100 10936.55 13.2
|
||||
793900 91298.15 11.85
|
||||
794000 91310.00 11.5
|
||||
+235
@@ -0,0 +1,235 @@
|
||||
income tax percent
|
||||
------ --- -------
|
||||
0 0.00 0
|
||||
12348 0.00 14.228628
|
||||
12598 35.57 14.685882
|
||||
12848 72.29 15.143138
|
||||
13098 110.14 15.600392
|
||||
13348 149.15 16.057648
|
||||
13598 189.29 16.514902
|
||||
13848 230.58 16.972158
|
||||
14098 273.01 17.429413
|
||||
14348 316.58 17.886668
|
||||
14598 361.30 18.343922
|
||||
14848 407.16 18.801178
|
||||
15098 454.16 19.258432
|
||||
15348 502.31 19.715688
|
||||
15598 551.60 20.172942
|
||||
15848 602.03 20.630198
|
||||
16098 653.60 21.087453
|
||||
16348 706.32 21.544707
|
||||
16598 760.18 22.001963
|
||||
16848 815.19 22.459217
|
||||
17098 871.34 22.916473
|
||||
17348 928.63 23.373727
|
||||
17598 987.06 23.786172
|
||||
17799 1034.87 24.012466
|
||||
18049 1094.90 24.099825
|
||||
18299 1155.15 24.186375
|
||||
18549 1215.62 24.272925
|
||||
18799 1276.30 24.359475
|
||||
19049 1337.20 24.446025
|
||||
19299 1398.31 24.532575
|
||||
19549 1459.65 24.619125
|
||||
19799 1521.19 24.705675
|
||||
20049 1582.96 24.792225
|
||||
20299 1644.94 24.878775
|
||||
20549 1707.14 24.965325
|
||||
20799 1769.55 25.051875
|
||||
21049 1832.18 25.138425
|
||||
21299 1895.02 25.224975
|
||||
21549 1958.09 25.311525
|
||||
21799 2021.37 25.398075
|
||||
22049 2084.86 25.484625
|
||||
22299 2148.57 25.571175
|
||||
22549 2212.50 25.657725
|
||||
22799 2276.64 25.744275
|
||||
23049 2341.01 25.830825
|
||||
23299 2405.58 25.917375
|
||||
23549 2470.38 26.003925
|
||||
23799 2535.39 26.090475
|
||||
24049 2600.61 26.177025
|
||||
24299 2666.05 26.263575
|
||||
24549 2731.71 26.350125
|
||||
24799 2797.59 26.436675
|
||||
25049 2863.68 26.523225
|
||||
25299 2929.99 26.609775
|
||||
25549 2996.51 26.696325
|
||||
25799 3063.25 26.782875
|
||||
26049 3130.21 26.869425
|
||||
26299 3197.38 26.955975
|
||||
26549 3264.77 27.042525
|
||||
26799 3332.38 27.129075
|
||||
27049 3400.20 27.215625
|
||||
27299 3468.24 27.302175
|
||||
27549 3536.50 27.388725
|
||||
27799 3604.97 27.475275
|
||||
28049 3673.66 27.561825
|
||||
28299 3742.56 27.648375
|
||||
28549 3811.68 27.734925
|
||||
28799 3881.02 27.821475
|
||||
29049 3950.57 27.908025
|
||||
29299 4020.34 27.994575
|
||||
29549 4090.33 28.081125
|
||||
29799 4160.53 28.167675
|
||||
30049 4230.95 28.254225
|
||||
30299 4301.59 28.340775
|
||||
30549 4372.44 28.427325
|
||||
30799 4443.51 28.513875
|
||||
31049 4514.79 28.600425
|
||||
31299 4586.29 28.686975
|
||||
31549 4658.01 28.773525
|
||||
31799 4729.95 28.860075
|
||||
32049 4802.10 28.946625
|
||||
32299 4874.46 29.033175
|
||||
32549 4947.05 29.119725
|
||||
32799 5019.84 29.206275
|
||||
33049 5092.86 29.292825
|
||||
33299 5166.09 29.379375
|
||||
33549 5239.54 29.465925
|
||||
33799 5313.21 29.552475
|
||||
34049 5387.09 29.639025
|
||||
34299 5461.18 29.725575
|
||||
34549 5535.50 29.812125
|
||||
34799 5610.03 29.898675
|
||||
35049 5684.78 29.985225
|
||||
35299 5759.74 30.071775
|
||||
35549 5834.92 30.158325
|
||||
35799 5910.31 30.244875
|
||||
36049 5985.93 30.331425
|
||||
36299 6061.75 30.417975
|
||||
36549 6137.80 30.504525
|
||||
36799 6214.06 30.591075
|
||||
37049 6290.54 30.677625
|
||||
37299 6367.23 30.764175
|
||||
37549 6444.14 30.850725
|
||||
37799 6521.27 30.937275
|
||||
38049 6598.61 31.023825
|
||||
38299 6676.17 31.110375
|
||||
38549 6753.95 31.196925
|
||||
38799 6831.94 31.283475
|
||||
39049 6910.15 31.370025
|
||||
39299 6988.57 31.456575
|
||||
39549 7067.22 31.543125
|
||||
39799 7146.07 31.629675
|
||||
40049 7225.15 31.716225
|
||||
40299 7304.44 31.802775
|
||||
40549 7383.95 31.889325
|
||||
40799 7463.67 31.975875
|
||||
41049 7543.61 32.062425
|
||||
41299 7623.76 32.148975
|
||||
41549 7704.14 32.235525
|
||||
41799 7784.73 32.322075
|
||||
42049 7865.53 32.408625
|
||||
42299 7946.55 32.495175
|
||||
42549 8027.79 32.581725
|
||||
42799 8109.24 32.668275
|
||||
43049 8190.92 32.754825
|
||||
43299 8272.80 32.841375
|
||||
43549 8354.91 32.927925
|
||||
43799 8437.23 33.014475
|
||||
44049 8519.76 33.101025
|
||||
44299 8602.51 33.187575
|
||||
44549 8685.48 33.274125
|
||||
44799 8768.67 33.360675
|
||||
45049 8852.07 33.447225
|
||||
45299 8935.69 33.533775
|
||||
45549 9019.52 33.620325
|
||||
45799 9103.57 33.706875
|
||||
46049 9187.84 33.793425
|
||||
46299 9272.32 33.879975
|
||||
46549 9357.02 33.966525
|
||||
46799 9441.94 34.053075
|
||||
47049 9527.07 34.139625
|
||||
47299 9612.42 34.226175
|
||||
47549 9697.99 34.312725
|
||||
47799 9783.77 34.399275
|
||||
48049 9869.77 34.485825
|
||||
48299 9955.98 34.572375
|
||||
48549 10042.41 34.658925
|
||||
48799 10129.06 34.745475
|
||||
49049 10215.92 34.832025
|
||||
49299 10303.00 34.918575
|
||||
49549 10390.30 35.005125
|
||||
49799 10477.81 35.091675
|
||||
50049 10565.54 35.178225
|
||||
50299 10653.49 35.264775
|
||||
50549 10741.65 35.351325
|
||||
50799 10830.03 35.437875
|
||||
51049 10918.62 35.524425
|
||||
51299 11007.43 35.610975
|
||||
51549 11096.46 35.697525
|
||||
51799 11185.71 35.784075
|
||||
52049 11275.17 35.870625
|
||||
52299 11364.84 35.957175
|
||||
52549 11454.74 36.043725
|
||||
52799 11544.84 36.130275
|
||||
53049 11635.17 36.216825
|
||||
53299 11725.71 36.303375
|
||||
53549 11816.47 36.389925
|
||||
53799 11907.45 36.476475
|
||||
54049 11998.64 36.563025
|
||||
54299 12090.04 36.649575
|
||||
54549 12181.67 36.736125
|
||||
54799 12273.51 36.822675
|
||||
55049 12365.57 36.909225
|
||||
55299 12457.84 36.995775
|
||||
55549 12550.33 37.082325
|
||||
55799 12643.03 37.168875
|
||||
56049 12735.96 37.255425
|
||||
56299 12829.09 37.341975
|
||||
56549 12922.45 37.428525
|
||||
56799 13016.02 37.515075
|
||||
57049 13109.81 37.601625
|
||||
57299 13203.81 37.688175
|
||||
57549 13298.03 37.774725
|
||||
57799 13392.47 37.861275
|
||||
58049 13487.12 37.947825
|
||||
58299 13581.99 38.034375
|
||||
58549 13677.08 38.120925
|
||||
58799 13772.38 38.207475
|
||||
59049 13867.90 38.294025
|
||||
59299 13963.63 38.380575
|
||||
59549 14059.59 38.467125
|
||||
59799 14155.75 38.553675
|
||||
60049 14252.14 38.640225
|
||||
60299 14348.74 38.726775
|
||||
60549 14445.56 38.813325
|
||||
60799 14542.59 38.899875
|
||||
61049 14639.84 38.986425
|
||||
61299 14737.30 39.072975
|
||||
61549 14834.99 39.159525
|
||||
61799 14932.89 39.246075
|
||||
62049 15031.00 39.332625
|
||||
62299 15129.33 39.419175
|
||||
62549 15227.88 39.505725
|
||||
62799 15326.64 39.592275
|
||||
63049 15425.63 39.678825
|
||||
63299 15524.82 39.765375
|
||||
63549 15624.24 39.851925
|
||||
63799 15723.87 39.938475
|
||||
64049 15823.71 40.025025
|
||||
64299 15923.77 40.111575
|
||||
64549 16024.05 40.198125
|
||||
64799 16124.55 40.284675
|
||||
65049 16225.26 40.371225
|
||||
65299 16326.19 40.457775
|
||||
65549 16427.33 40.544325
|
||||
65799 16528.69 40.630875
|
||||
66049 16630.27 40.717425
|
||||
66299 16732.06 40.803975
|
||||
66549 16834.07 40.890525
|
||||
66799 16936.30 40.977075
|
||||
67049 17038.74 41.063625
|
||||
67299 17141.40 41.150175
|
||||
67549 17244.28 41.236725
|
||||
67799 17347.37 41.323275
|
||||
68049 17450.68 41.409825
|
||||
68299 17554.20 41.496375
|
||||
68549 17657.94 41.582925
|
||||
68799 17761.90 41.669475
|
||||
69049 17866.07 41.756025
|
||||
69299 17970.46 41.842575
|
||||
69549 18075.07 41.963277
|
||||
69879 18213.55 42.000014
|
||||
277826 105551.32 45
|
||||
@@ -0,0 +1,11 @@
|
||||
income tax percent
|
||||
------ --- -------
|
||||
0 0.00 0
|
||||
15900 0.00 1
|
||||
21100 52.00 3
|
||||
42300 688.00 4
|
||||
74000 1956.00 5
|
||||
105700 3541.00 6
|
||||
137400 5443.00 6.5
|
||||
169200 7510.00 7
|
||||
211400 10464.00 8
|
||||
+26
-1
@@ -23,8 +23,14 @@ rename Owner Person < owners
|
||||
## number / Number: number the rows (number: rows only, Number: rows and the head)
|
||||
number < owners
|
||||
Number < owners
|
||||
## record: print one record (the 3rd row)
|
||||
## record: print one record. 0 and 1 are the first row, 3 the third, -1 the last, -2 the one before
|
||||
record 3 < inventory
|
||||
record 0 < owners
|
||||
record -1 < owners
|
||||
record -2 < owners
|
||||
## beyond the end: an empty table; not a number: an error
|
||||
record 9 < owners
|
||||
record abc < owners
|
||||
## rmblank: drop empty rows; headon / headoff: add or strip the head
|
||||
column Item Cost < inventory | rmblank
|
||||
headoff < colors
|
||||
@@ -223,6 +229,12 @@ act
|
||||
cashflow < cash
|
||||
## tax: look up the tax for an income in a tax table
|
||||
tax 20000 < X83
|
||||
## tax also works with the real 2026 tables of DE, AT, CH (federal tax only) and LI (Landessteuer,
|
||||
## before the municipal surcharge of 150 % to 250 %); see README. Tax on an income of 60000:
|
||||
tax 60000 < taxDE2026
|
||||
tax 60000 < taxAT2026
|
||||
tax 60000 < taxCH2026
|
||||
tax 60000 < taxLI2026
|
||||
## explode: parts list -> all subparts with counts (part, amount)
|
||||
explode 10001 2 parttable
|
||||
## chartdup: look for duplicate account numbers and short names in chart
|
||||
@@ -265,3 +277,16 @@ bom ; cat bom
|
||||
## update.inv: change the stock of one item, with record locking (INV names the table)
|
||||
cp inventory inv ; printf '7\n9\n\n' | INV=inv update.inv | tail -n +2
|
||||
grep '^7' inv
|
||||
|
||||
## ===== Choosing rows on the terminal =====
|
||||
## pick shows a table, lets you scroll, filter (/) and mark rows (Space, -m), and writes the
|
||||
## chosen rows as a table, so it can sit in a pipe. It needs a terminal; -k feeds the keys instead.
|
||||
## Try it: pick -m < inventory | total Amount Value
|
||||
## mark the 2nd and 5th row (down, space, down, down, space) and finish with enter
|
||||
pick -m -k 'down space down down space enter' < inventory
|
||||
## the same chosen rows go on into total
|
||||
pick -m -k 'down space down down space enter' < inventory | total Amount Value
|
||||
## filter with / (type, enter), then choose the row that is left
|
||||
pick -k '/clamps enter enter' < inventory
|
||||
## Esc cancels: nothing on stdout, exit status 1
|
||||
pick -k esc < inventory ; echo "status $?"
|
||||
|
||||
+55
-3
@@ -86,11 +86,29 @@ Number Item Owner
|
||||
1 1 ann
|
||||
2 3 bob
|
||||
3 6 cy
|
||||
## record: print one record (the 3rd row)
|
||||
## record: print one record. 0 and 1 are the first row, 3 the third, -1 the last, -2 the one before
|
||||
$ record 3 < inventory
|
||||
Item Amount Cost Value Description
|
||||
---- ------ ---- ----- -----------
|
||||
3 5 8.00 40.00 clamps
|
||||
$ record 0 < owners
|
||||
Item Owner
|
||||
---- -----
|
||||
1 ann
|
||||
$ record -1 < owners
|
||||
Item Owner
|
||||
---- -----
|
||||
6 cy
|
||||
$ record -2 < owners
|
||||
Item Owner
|
||||
---- -----
|
||||
3 bob
|
||||
## beyond the end: an empty table; not a number: an error
|
||||
$ record 9 < owners
|
||||
Item Owner
|
||||
---- -----
|
||||
$ record abc < owners
|
||||
record: 'abc' is not a record number (1, 2, 3 ... or -1 for the last)
|
||||
## rmblank: drop empty rows; headon / headoff: add or strip the head
|
||||
$ column Item Cost < inventory | rmblank
|
||||
Item Cost
|
||||
@@ -845,9 +863,9 @@ Item Amount Cost Value Description
|
||||
## tabletom4 / tabletosed / tabletostruct were shown above
|
||||
## ===== Help and the terminal =====
|
||||
$ rdb --version
|
||||
rdb 0.4.0
|
||||
rdb 0.6.0
|
||||
$ howmany
|
||||
135
|
||||
136
|
||||
$ whatis row
|
||||
row - make a new table where rows match logical condition
|
||||
row row 'column condition column-or-value ..' < table
|
||||
@@ -935,6 +953,16 @@ Date Amount Balance Description
|
||||
## tax: look up the tax for an income in a tax table
|
||||
$ tax 20000 < X83
|
||||
3369
|
||||
## tax also works with the real 2026 tables of DE, AT, CH (federal tax only) and LI (Landessteuer,
|
||||
## before the municipal surcharge of 150 % to 250 %); see README. Tax on an income of 60000:
|
||||
$ tax 60000 < taxDE2026
|
||||
14233
|
||||
$ tax 60000 < taxAT2026
|
||||
15447
|
||||
$ tax 60000 < taxCH2026
|
||||
671
|
||||
$ tax 60000 < taxLI2026
|
||||
1396
|
||||
## explode: parts list -> all subparts with counts (part, amount)
|
||||
$ explode 10001 2 parttable
|
||||
Part Amount
|
||||
@@ -1107,3 +1135,27 @@ Item Number: (or hit Return key to exit.) Item=7 Amount=5 Cost=175.00 Value=8
|
||||
Amount Amount: Item Number: (or hit Return key to exit.)
|
||||
$ grep '^7' inv
|
||||
7 9 175.00 1575 scales
|
||||
## ===== Choosing rows on the terminal =====
|
||||
## pick shows a table, lets you scroll, filter (/) and mark rows (Space, -m), and writes the
|
||||
## chosen rows as a table, so it can sit in a pipe. It needs a terminal; -k feeds the keys instead.
|
||||
## Try it: pick -m < inventory | total Amount Value
|
||||
## mark the 2nd and 5th row (down, space, down, down, space) and finish with enter
|
||||
$ pick -m -k 'down space down down space enter' < inventory
|
||||
Item Amount Cost Value Description
|
||||
---- ------ ---- ----- -----------
|
||||
2 100 0.50 50.00 test tubes
|
||||
5 99 2.45 242.55 cleaning cloth
|
||||
## the same chosen rows go on into total
|
||||
$ pick -m -k 'down space down down space enter' < inventory | total Amount Value
|
||||
Item Amount Cost Value Description
|
||||
---- ------ ---- ----- -----------
|
||||
199 292.55
|
||||
## filter with / (type, enter), then choose the row that is left
|
||||
$ pick -k '/clamps enter enter' < inventory
|
||||
Item Amount Cost Value Description
|
||||
---- ------ ---- ----- -----------
|
||||
3 6 8.00 48.00 clamps
|
||||
## Esc cancels: nothing on stdout, exit status 1
|
||||
$ pick -k esc < inventory ; echo "status $?"
|
||||
pick: nothing chosen
|
||||
status 1
|
||||
|
||||
@@ -0,0 +1,218 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require 'io/console'
|
||||
|
||||
# pick -- look at a table on the terminal, scroll through it and choose one or
|
||||
# several rows. The table comes in on the standard input, the chosen rows go
|
||||
# out on the standard output as a table, so it sits in the middle of a pipe:
|
||||
# column Item Description < inventory | pick -m | total Item
|
||||
# The keys are read from /dev/tty (or, for scripts and tests, from -k).
|
||||
module Rdb
|
||||
module Pick
|
||||
KEYS = {
|
||||
'up' => "\e[A", 'down' => "\e[B", 'pgup' => "\e[5~", 'pgdn' => "\e[6~",
|
||||
'home' => "\e[H", 'end' => "\e[F", 'enter' => "\r", 'space' => ' ',
|
||||
'esc' => "\e", 'backspace' => "\x7f"
|
||||
}.freeze
|
||||
|
||||
# The state of one session: which rows are shown, where the cursor is,
|
||||
# which rows are marked. Knows nothing about the terminal.
|
||||
class Session
|
||||
attr_reader :head, :rows, :cursor, :top, :filter, :marked
|
||||
|
||||
def initialize(head, rows, multi: false)
|
||||
@head = head
|
||||
@rows = rows
|
||||
@multi = multi
|
||||
@marked = []
|
||||
@filter = +''
|
||||
@typing = false
|
||||
@cursor = 0
|
||||
@top = 0
|
||||
@height = 20
|
||||
@width = 80
|
||||
refilter
|
||||
end
|
||||
|
||||
def resize(height, width)
|
||||
@height = [height, 1].max
|
||||
@width = [width, 20].max
|
||||
scroll
|
||||
end
|
||||
|
||||
def typing? = @typing
|
||||
|
||||
# Indices (into rows) of the rows that pass the filter.
|
||||
def visible = @visible
|
||||
|
||||
def current = @visible[@cursor]
|
||||
|
||||
# Rows to hand on: the marked ones, else the row under the cursor.
|
||||
def selected
|
||||
idx = @marked.empty? ? [current].compact : @marked.sort
|
||||
idx.map { |i| @rows[i] }
|
||||
end
|
||||
|
||||
# Returns :accept or :cancel when the session is over.
|
||||
def feed(key)
|
||||
@typing ? type(key) : browse(key)
|
||||
end
|
||||
|
||||
def render
|
||||
widths = @head.each_index.map { |i| ([@head[i]] + @rows.map { |r| r[i].to_s }).map(&:size).max }
|
||||
line = ->(cells) { cells.each_with_index.map { |c, i| c.to_s.ljust(widths[i]) }.join(' ').rstrip }
|
||||
out = +"\e[?25l\e[H"
|
||||
out << "\e[K \e[1m#{fit(line.(@head))}\e[0m\r\n"
|
||||
out << "\e[K #{fit(widths.map { |w| '-' * w }.join(' '))}\r\n"
|
||||
@height.times do |n|
|
||||
pos = @top + n
|
||||
i = @visible[pos]
|
||||
out << "\e[K"
|
||||
if i
|
||||
text = "#{@marked.include?(i) ? '*' : ' '} #{fit(line.(@rows[i]))}"
|
||||
out << (pos == @cursor ? "\e[7m#{text}\e[0m" : text)
|
||||
end
|
||||
out << "\r\n"
|
||||
end
|
||||
out << "\e[K#{status}"
|
||||
out
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def fit(text) = text[0, @width - 2]
|
||||
|
||||
def status
|
||||
keys = @typing ? 'type to filter Enter keep Esc clear' : "Enter ok#{' Space mark a all' if @multi} / filter Esc cancel"
|
||||
filter = @filter.empty? && !@typing ? '' : " /#{@filter}#{'_' if @typing}"
|
||||
"\e[7m #{@visible.empty? ? 0 : @cursor + 1}/#{@visible.size} of #{@rows.size}" \
|
||||
"#{" marked #{@marked.size}" if @multi}#{filter} #{keys} \e[0m"
|
||||
end
|
||||
|
||||
def browse(key)
|
||||
case key
|
||||
when "\e[A" then move(-1)
|
||||
when "\e[B" then move(1)
|
||||
when "\e[5~" then move(-@height)
|
||||
when "\e[6~" then move(@height)
|
||||
when "\e[H", "\e[1~", "\eOH" then move(-@visible.size)
|
||||
when "\e[F", "\e[4~", "\eOF" then move(@visible.size)
|
||||
when "\r", "\n" then return selected.empty? ? :cancel : :accept
|
||||
when ' ' then toggle
|
||||
when 'a' then mark_all if @multi
|
||||
when '/' then @typing = true
|
||||
when "\x03", "\e", 'q' then return :cancel
|
||||
end
|
||||
nil
|
||||
end
|
||||
|
||||
def type(key)
|
||||
case key
|
||||
when "\r", "\n" then @typing = false
|
||||
when "\e" then @filter.clear; @typing = false; refilter
|
||||
when "\x03" then return :cancel
|
||||
when "\x7f", "\b" then @filter.chop!; refilter
|
||||
else
|
||||
if key.size == 1 && key.ord >= 32
|
||||
@filter << key
|
||||
refilter
|
||||
end
|
||||
end
|
||||
nil
|
||||
end
|
||||
|
||||
def refilter
|
||||
needle = @filter.downcase
|
||||
@visible = @rows.each_index.select { |i| needle.empty? || @rows[i].join("\t").downcase.include?(needle) }
|
||||
@cursor = [@cursor, [@visible.size - 1, 0].max].min
|
||||
scroll
|
||||
end
|
||||
|
||||
def move(delta)
|
||||
@cursor = (@cursor + delta).clamp(0, [@visible.size - 1, 0].max)
|
||||
scroll
|
||||
end
|
||||
|
||||
def scroll
|
||||
@top = @cursor if @cursor < @top
|
||||
@top = @cursor - @height + 1 if @cursor >= @top + @height
|
||||
@top = 0 if @top.negative?
|
||||
end
|
||||
|
||||
def toggle
|
||||
return unless @multi && current
|
||||
|
||||
@marked.include?(current) ? @marked.delete(current) : @marked << current
|
||||
move(1)
|
||||
end
|
||||
|
||||
def mark_all
|
||||
all = @visible.all? { |i| @marked.include?(i) }
|
||||
all ? @marked -= @visible : @marked |= @visible
|
||||
end
|
||||
end
|
||||
|
||||
def self.run(session, tty)
|
||||
tty.raw do
|
||||
tty.write "\e[?1049h"
|
||||
begin
|
||||
loop do
|
||||
rows, cols = tty.winsize
|
||||
session.resize((rows.zero? ? 24 : rows) - 3, cols.zero? ? 80 : cols)
|
||||
tty.write session.render
|
||||
key = begin
|
||||
Rdb::Mask.read_key(tty)
|
||||
rescue EOFError
|
||||
"\x03"
|
||||
end
|
||||
result = session.feed(key)
|
||||
return result if result
|
||||
end
|
||||
ensure
|
||||
tty.write "\e[?25h\e[?1049l"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# Words of a -k script: a name from KEYS, or else every character is one key.
|
||||
def self.script(words)
|
||||
words.split.flat_map { |w| KEYS.key?(w) ? [KEYS[w]] : w.chars }
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
Rdb.register('pick', 'Scroll through a table and choose one or more rows', 'pick [-m] [-k keys] < table > chosenrows') do |args|
|
||||
multi = !args.delete('-m').nil?
|
||||
keys = nil
|
||||
if (i = args.index('-k'))
|
||||
keys = args[i + 1] or die 'usage: pick [-m] [-k keys] < table'
|
||||
args.slice!(i, 2)
|
||||
end
|
||||
die 'usage: pick [-m] [-k "down space enter"] < table > chosenrows' unless args.empty?
|
||||
|
||||
t = table_in
|
||||
die 'pick: the table is empty' if t.rows.empty?
|
||||
session = Rdb::Pick::Session.new(t.head, t.rows, multi: multi)
|
||||
|
||||
result = nil
|
||||
if keys
|
||||
session.resize(21, 80)
|
||||
Rdb::Pick.script(keys).each do |k|
|
||||
result = session.feed(k)
|
||||
break if result
|
||||
end
|
||||
else
|
||||
tty = begin
|
||||
File.open('/dev/tty', 'r+')
|
||||
rescue SystemCallError
|
||||
die 'no terminal available (pick takes its keys from /dev/tty; see -k)'
|
||||
end
|
||||
result = Rdb::Pick.run(session, tty)
|
||||
tty.close
|
||||
end
|
||||
unless result == :accept
|
||||
warn 'pick: nothing chosen'
|
||||
exit 1
|
||||
end
|
||||
Rdb::Table.new(t.head, session.selected).write
|
||||
end
|
||||
@@ -72,8 +72,12 @@ end
|
||||
|
||||
Rdb.register('record', 'Find and output a record from a table', 'record number < table') do |args|
|
||||
die 'usage: record number < table' unless args.size == 1
|
||||
die "'#{args[0]}' is not a record number (1, 2, 3 ... or -1 for the last)" unless args[0].match?(/\A[+-]?\d+\z/)
|
||||
|
||||
# 0 and 1 are the first record; -1 is the last, -2 the one before it ...
|
||||
t = table_in
|
||||
row = t.rows[args[0].to_i - 1]
|
||||
n = args[0].to_i
|
||||
row = n.negative? ? (t.rows[n] if -n <= t.rows.size) : t.rows[[n, 1].max - 1]
|
||||
t.with(rows: row ? [row] : []).write
|
||||
end
|
||||
|
||||
|
||||
+3
-2
@@ -81,9 +81,10 @@ end
|
||||
# ---- manual pages ---------------------------------------------------------------
|
||||
|
||||
def troff_clean(str)
|
||||
str.gsub(/\\f[BIRP]/, '').gsub('\\e', '\\').gsub('\\(em', ' -- ').gsub('\\(aa', "'")
|
||||
# a literal backslash (\\ or \e) is parked in \0 so that later rules cannot see it
|
||||
str.gsub('\\\\', "\0").gsub(/\\f[BIRP]/, '').gsub('\\e', "\0").gsub('\\(em', ' -- ').gsub('\\(aa', "'")
|
||||
.gsub('\\-', '-').gsub('\\&', '').gsub('\\*(UX', 'UNIX').gsub('``', '"').gsub("''", '"').gsub('\\ ', ' ')
|
||||
.gsub(/\\\(..|\\\*\(..|\\[a-z]/, '').gsub('\\\\', '\\')
|
||||
.gsub(/\\\(..|\\\*\(..|\\[a-z]/, '').gsub("\0", '\\')
|
||||
end
|
||||
|
||||
def render_man(src)
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module Rdb
|
||||
VERSION = '0.4.0'
|
||||
VERSION = '0.6.0'
|
||||
end
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
.TH pick "" "" "/rdb Command"
|
||||
.PC "Scroll through a table and choose one or more rows"
|
||||
\fBpick \fB[ -m ] [ -k \fIkeys\fB ] < \fItable \fB> \fIchosenrows\fR
|
||||
.PP
|
||||
.II "pick^choose rows"
|
||||
.II "table^scroll"
|
||||
.I "(Ruby re-creation only; the original /rdb has no such command.)"
|
||||
.PP
|
||||
The command
|
||||
.B pick
|
||||
shows
|
||||
.I table
|
||||
on the terminal, lets the user scroll through it, and writes the rows
|
||||
that were chosen as a table on the standard output.
|
||||
It therefore fits into the middle of a pipe:
|
||||
.DM
|
||||
column Item Description < inventory | pick -m | jointable - owners
|
||||
pick -m < inventory | total Amount Value
|
||||
.DE
|
||||
.PP
|
||||
The table is read from the standard input;
|
||||
the keys are read from the terminal (\fB/dev/tty\fR).
|
||||
A list is read like a table, and the output is always a table.
|
||||
The head and the dash line stay at the top while the rows scroll.
|
||||
.SH Keys
|
||||
.nf
|
||||
Up, Down move the cursor one row
|
||||
PgUp, PgDn move a page
|
||||
Home, End first or last row
|
||||
Enter choose and finish
|
||||
Space mark the row, move down (with -m)
|
||||
a mark all shown rows; again, unmark them (with -m)
|
||||
/ filter: type text, shown rows must contain it
|
||||
(Enter keeps the filter, Esc clears it)
|
||||
Esc, q, Ctrl-C cancel
|
||||
.fi
|
||||
.PP
|
||||
.B pick
|
||||
writes the marked rows in table order.
|
||||
If no row is marked, it writes the row under the cursor.
|
||||
Marks stay when a filter hides the row.
|
||||
If the user cancels, or the filter leaves no row to choose,
|
||||
.B pick
|
||||
writes nothing and exits with status 1.
|
||||
.SH Options
|
||||
.IP \fB-m\fR 0.5i
|
||||
Allow several rows: Space marks, \fBa\fR marks all.
|
||||
Without it, Enter chooses the row under the cursor.
|
||||
.IP "\fB-k\fR \fIkeys\fR"
|
||||
Take the keys from this string instead of the terminal.
|
||||
Words are key names:
|
||||
.BR up ,
|
||||
.BR down ,
|
||||
.BR pgup ,
|
||||
.BR pgdn ,
|
||||
.BR home ,
|
||||
.BR end ,
|
||||
.BR enter ,
|
||||
.BR space ,
|
||||
.BR esc ,
|
||||
.BR backspace ;
|
||||
any other word is typed character by character.
|
||||
This is for scripts, demonstrations and tests.
|
||||
.SH Example
|
||||
.DM
|
||||
pick -m -k 'down space down down space enter' < inventory
|
||||
.DE
|
||||
.PP
|
||||
marks the second and the fifth row and prints:
|
||||
.DM
|
||||
Item Amount Cost Value Description
|
||||
---- ------ ---- ------ --------------
|
||||
2 100 0.50 50.00 test tubes
|
||||
5 99 2.45 242.55 cleaning cloth
|
||||
.DE
|
||||
.SH "See Also"
|
||||
.B
|
||||
display,
|
||||
mask,
|
||||
row,
|
||||
search
|
||||
.R
|
||||
.SO rdb
|
||||
@@ -59,6 +59,17 @@ you see output in the following format:
|
||||
.DM
|
||||
Item 1 is rubber gloves
|
||||
.DE
|
||||
.SH "Record Numbers"
|
||||
.I "(Ruby re-creation only.)"
|
||||
Records are counted from 1.
|
||||
The numbers 0 and 1 both mean the first record.
|
||||
A negative number counts from the end:
|
||||
.B \-1
|
||||
is the last record,
|
||||
.B \-2
|
||||
the one before it, and so on.
|
||||
A number beyond either end gives a table without records.
|
||||
Anything that is not a whole number is an error.
|
||||
.SH "See Also"
|
||||
.B
|
||||
row,
|
||||
|
||||
+26
@@ -191,6 +191,32 @@ Names that are also
|
||||
reserved words or are followed by
|
||||
.B (
|
||||
are never taken for columns.
|
||||
.SH "Using select in a Shell"
|
||||
.I "(Ruby re-creation only.)"
|
||||
.B select
|
||||
is another name for
|
||||
.BR row ,
|
||||
but in
|
||||
.BR bash ,
|
||||
.B zsh
|
||||
and
|
||||
.B ksh
|
||||
``select'' is a reserved word of the shell.
|
||||
The shell recognizes it before it looks for a program, so
|
||||
.DM
|
||||
select 'Cost > 5' < inventory
|
||||
.DE
|
||||
.PP
|
||||
stops with a syntax error at the ``<''.
|
||||
Use the name
|
||||
.B row
|
||||
instead, or protect the word from the shell:
|
||||
.DM
|
||||
row 'Cost > 5' < inventory
|
||||
\\select 'Cost > 5' < inventory
|
||||
command select 'Cost > 5' < inventory
|
||||
rdb select 'Cost > 5' < inventory
|
||||
.DE
|
||||
.SH "See Also"
|
||||
.B
|
||||
compute,
|
||||
|
||||
@@ -5,6 +5,25 @@ require 'pty'
|
||||
require_relative '../lib/rdb'
|
||||
|
||||
class SelectionTest < RdbCase
|
||||
def test_record_zero_and_one_are_the_first_and_negatives_count_from_the_end
|
||||
{ '0' => '1', '1' => '1', '2' => '3', '3' => '6', '-1' => '6', '-2' => '3', '-3' => '1', '+2' => '3' }.each do |n, item|
|
||||
assert_equal [[item]], data(rdb('record', n, file: 'owners')).map { |r| r.first(1) }, "record #{n}"
|
||||
end
|
||||
end
|
||||
|
||||
def test_record_beyond_either_end_is_an_empty_table
|
||||
%w[4 9 -4 -9].each { |n| assert_equal [], data(rdb('record', n, file: 'owners')), "record #{n}" }
|
||||
end
|
||||
|
||||
def test_record_rejects_junk
|
||||
%w[abc 2.5 1e3].each do |n|
|
||||
out, err, st = run3('record', n, stdin: File.read(File.join(@dir, 'owners')))
|
||||
refute st.success?, "record #{n} should fail"
|
||||
assert_equal '', out
|
||||
assert_match(/not a record number/, err)
|
||||
end
|
||||
end
|
||||
|
||||
def test_row_numeric_condition
|
||||
out = rdb('row', 'Cost > 5', file: 'inventory')
|
||||
assert_equal %w[3 6 7], data(out).map(&:first)
|
||||
@@ -618,3 +637,145 @@ class MaskTest < RdbCase
|
||||
assert_equal '', table
|
||||
end
|
||||
end
|
||||
|
||||
class PickTest < RdbCase
|
||||
def session(multi: true, height: 3)
|
||||
t = Rdb::Table.read(File.open(File.join(DEMO, 'inventory')))
|
||||
Rdb::Pick::Session.new(t.head, t.rows, multi: multi).tap { |x| x.resize(height, 80) }
|
||||
end
|
||||
|
||||
def keys(sess, words) = Rdb::Pick.script(words).map { |k| sess.feed(k) }.compact.last
|
||||
|
||||
def test_mark_and_select
|
||||
s = session
|
||||
assert_equal :accept, keys(s, 'down space down down space enter')
|
||||
assert_equal %w[2 5], s.selected.map(&:first)
|
||||
end
|
||||
|
||||
def test_single_mode_takes_the_cursor_row_and_ignores_space
|
||||
s = session(multi: false)
|
||||
assert_equal :accept, keys(s, 'down down space enter')
|
||||
assert_equal %w[3], s.selected.map(&:first)
|
||||
end
|
||||
|
||||
def test_scrolling_keeps_the_cursor_in_view
|
||||
s = session(height: 3)
|
||||
keys(s, 'down down down down')
|
||||
assert_equal [4, 2], [s.cursor, s.top]
|
||||
keys(s, 'end')
|
||||
assert_equal [6, 4], [s.cursor, s.top]
|
||||
keys(s, 'pgup')
|
||||
assert_equal 3, s.cursor
|
||||
keys(s, 'home')
|
||||
assert_equal [0, 0], [s.cursor, s.top]
|
||||
end
|
||||
|
||||
def test_filter_and_clear
|
||||
s = session
|
||||
keys(s, '/clam')
|
||||
assert_predicate s, :typing?
|
||||
keys(s, 'enter')
|
||||
assert_equal [2], s.visible
|
||||
keys(s, '/ esc')
|
||||
assert_equal 7, s.visible.size
|
||||
end
|
||||
|
||||
def test_marks_survive_a_filter_and_a_all_toggles
|
||||
s = session
|
||||
keys(s, 'space /scales enter')
|
||||
assert_equal [0], s.marked
|
||||
keys(s, 'a')
|
||||
assert_equal [0, 6], s.marked.sort
|
||||
keys(s, 'a')
|
||||
assert_equal [0], s.marked
|
||||
end
|
||||
|
||||
def test_cancel_and_empty_choice
|
||||
assert_equal :cancel, keys(session, 'esc')
|
||||
assert_equal :cancel, keys(session, '/zzz enter enter')
|
||||
end
|
||||
|
||||
def test_command_with_keys_script_is_a_filter
|
||||
out = rdb('pick', '-m', '-k', 'down space down down space enter', file: 'inventory')
|
||||
assert_equal %w[2 5], data(out).map(&:first)
|
||||
assert_equal '292.55', data(rdb('total', 'Value', stdin: out)).first[3]
|
||||
end
|
||||
|
||||
def test_command_cancelled_exits_1_with_no_output
|
||||
out, _err, st = run3('pick', '-k', 'esc', stdin: File.read(File.join(@dir, 'inventory')))
|
||||
refute st.success?
|
||||
assert_equal '', out
|
||||
end
|
||||
|
||||
def test_command_reads_a_list
|
||||
out = rdb('pick', '-k', 'down enter', stdin: rdb('tabletolist', file: 'owners'))
|
||||
assert_equal [%w[3 bob]], data(out)
|
||||
end
|
||||
|
||||
def run_on_pty(*key_strokes)
|
||||
out = File.join(@dir, 'pick.out')
|
||||
result = nil
|
||||
PTY.spawn({ 'PATH' => "#{BIN}:#{ENV['PATH']}" }, 'sh', '-c', "cd #{@dir} && pick -m < inventory > pick.out") do |r, w, pid|
|
||||
sleep 0.5
|
||||
key_strokes.each do |k|
|
||||
w.write k
|
||||
sleep 0.1
|
||||
r.read_nonblock(65_536) rescue nil
|
||||
end
|
||||
sleep 0.3
|
||||
r.read_nonblock(65_536) rescue nil
|
||||
_, st = Process.wait2(pid)
|
||||
result = [File.exist?(out) ? File.read(out) : '', st.exitstatus]
|
||||
end
|
||||
result
|
||||
end
|
||||
|
||||
def test_pick_on_a_terminal
|
||||
table, status = run_on_pty("\e[B", ' ', "\e[B", ' ', "\r")
|
||||
assert_equal 0, status
|
||||
assert_equal %w[2 4], data(table).map(&:first)
|
||||
end
|
||||
|
||||
def test_pick_on_a_terminal_cancel
|
||||
table, status = run_on_pty("\e[B", "\e")
|
||||
assert_equal 1, status
|
||||
assert_equal '', table
|
||||
end
|
||||
end
|
||||
|
||||
class TaxTablesTest < RdbCase
|
||||
def tax(income, table) = rdb('tax', income.to_s, file: table).to_i
|
||||
|
||||
def test_at_2026
|
||||
assert_equal 0, tax(13_539, 'taxAT2026')
|
||||
assert_equal 11_447, tax(50_000, 'taxAT2026') # 6 030.40 + 13 542 * 40 %
|
||||
assert_equal 33_818, tax(100_000, 'taxAT2026')
|
||||
end
|
||||
|
||||
def test_ch_federal_2026
|
||||
assert_equal 0, tax(15_200, 'taxCH2026')
|
||||
assert_equal 401, tax(50_000, 'taxCH2026')
|
||||
assert_equal 2684, tax(100_000, 'taxCH2026')
|
||||
assert_equal 91_310, tax(794_000, 'taxCH2026')
|
||||
end
|
||||
|
||||
def test_li_landessteuer_2026
|
||||
assert_equal 996, tax(50_000, 'taxLI2026') # 0,04 * x - 1 004
|
||||
assert_equal 3256, tax(100_000, 'taxLI2026') # 0,05 * x - 1 744
|
||||
assert_equal 10_464, tax(211_400, 'taxLI2026')
|
||||
assert_equal 0, tax(15_855, 'taxLI2026')
|
||||
end
|
||||
|
||||
# § 32a EStG: the table is a straight-line approximation of the curved zones
|
||||
def test_de_2026_follows_the_formula
|
||||
[12_348, 14_000, 17_799, 20_000, 33_333, 50_000, 69_878, 100_000, 277_825, 300_000].each do |x|
|
||||
exact = if x <= 12_348 then 0
|
||||
elsif x <= 17_799 then y = (x - 12_348) / 10_000.0; ((914.51 * y) + 1400) * y
|
||||
elsif x <= 69_878 then z = (x - 17_799) / 10_000.0; (((173.10 * z) + 2397) * z) + 1034.87
|
||||
elsif x <= 277_825 then (0.42 * x) - 11_135.63
|
||||
else (0.45 * x) - 19_470.38
|
||||
end
|
||||
assert_in_delta exact.floor, tax(x, 'taxDE2026'), 1, "income #{x}"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
# Writes the 2026 income tax tables in the format of `tax` (income, tax, percent)
|
||||
# into demo/. Sources: § 32a EStG (DE), § 33 EStG (AT), Tarif 2026 Art. 36 DBG (CH,
|
||||
# ESTV Rundschreiben 2-215-D-2025), Art. 19 SteG LR 640.0, Fassung 01.07.2026 (LI).
|
||||
# ruby tools/gen_tax_tables.rb
|
||||
|
||||
DEMO = File.expand_path('../demo', __dir__)
|
||||
|
||||
def write_table(name, points)
|
||||
rows = points.map { |i, t, p| [i.to_s, format('%.2f', t), format('%.6f', p).sub(/0+\z/, '').sub(/\.\z/, '')] }
|
||||
File.write(File.join(DEMO, name), "income\ttax\tpercent\n------\t---\t-------\n#{rows.map { |r| r.join("\t") }.join("\n")}\n")
|
||||
end
|
||||
|
||||
# --- Germany: single assessment, § 32a Abs. 1 (the zones 2 and 3 are curves, so
|
||||
# the table approximates them with short straight pieces; error below one euro)
|
||||
def de_tax(x)
|
||||
if x <= 12_348 then 0.0
|
||||
elsif x <= 17_799 then y = (x - 12_348) / 10_000.0; ((914.51 * y) + 1400) * y
|
||||
elsif x <= 69_878 then z = (x - 17_799) / 10_000.0; (((173.10 * z) + 2397) * z) + 1034.87
|
||||
elsif x <= 277_825 then (0.42 * x) - 11_135.63
|
||||
else (0.45 * x) - 19_470.38
|
||||
end
|
||||
end
|
||||
|
||||
de = [12_348] + (12_598..17_798).step(250).to_a + [17_799] + (18_049..69_549).step(250).to_a + [69_879, 277_826]
|
||||
pts = de.each_with_index.map do |i, n|
|
||||
nxt = de[n + 1]
|
||||
slope = nxt ? (de_tax(nxt) - de_tax(i)) / (nxt - i) * 100 : 45.0
|
||||
[i, de_tax(i), slope]
|
||||
end
|
||||
write_table('taxDE2026', [[0, 0.0, 0.0]] + pts)
|
||||
|
||||
# --- Austria: § 33 EStG, the tariff before the Absetzbeträge
|
||||
at = [[13_539, 20], [21_992, 30], [36_458, 40], [70_365, 48], [104_859, 50], [1_000_000, 55]]
|
||||
acc = 0.0
|
||||
prev = [0, 0]
|
||||
pts = [[0, 0.0, 0.0]]
|
||||
at.each do |limit, rate|
|
||||
acc += (limit - prev[0]) * prev[1] / 100.0
|
||||
pts << [limit, acc, rate]
|
||||
prev = [limit, rate]
|
||||
end
|
||||
write_table('taxAT2026', pts)
|
||||
|
||||
# --- Switzerland: direct federal tax, Art. 36 DBG, single persons (without canton and municipality)
|
||||
ch = [[0, 0, 0], [15_200, 0, 0.77], [33_200, 138.60, 0.88], [43_500, 229.20, 2.64], [58_000, 612.00, 2.97],
|
||||
[76_200, 1152.50, 5.94], [82_100, 1502.95, 6.60], [108_900, 3271.75, 8.80], [141_500, 6140.55, 11.00],
|
||||
[185_100, 10_936.55, 13.20], [793_900, 91_298.15, 11.85], [794_000, 91_310.00, 11.50]]
|
||||
write_table('taxCH2026', ch)
|
||||
|
||||
# --- Liechtenstein: Landessteuer, Art. 19 Abs. 1 Bst. a SteG; the municipal surcharge
|
||||
# (150 % to 250 % of it) comes on top: total = Landessteuer * (1 + surcharge / 100)
|
||||
li_rates = [[15_900, 1], [21_100, 3], [42_300, 4], [74_000, 5], [105_700, 6], [137_400, 6.5], [169_200, 7], [211_400, 8]]
|
||||
acc = 0.0
|
||||
prev = [15_855, 0]
|
||||
pts = [[0, 0.0, 0.0]]
|
||||
li_rates.each do |limit, rate|
|
||||
acc += (limit - prev[0]) * prev[1] / 100.0 if prev[1].positive?
|
||||
pts << [limit, acc, rate]
|
||||
prev = [limit, rate]
|
||||
end
|
||||
write_table('taxLI2026', pts)
|
||||
Reference in New Issue
Block a user