aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--service/src/test/java/market/guess/service/Ex2LoaderTest.java135
-rw-r--r--service/src/test/java/market/guess/service/OrderBookSimulationTest.java235
-rw-r--r--service/src/test/java/market/guess/service/OrderBookTradingTest.java416
-rw-r--r--service/src/test/java/market/guess/service/catalog/infrastructure/adapter/LedgerMapperTest.java25
-rw-r--r--ui-desktop/src/test/java/market/guess/ui/desktop/AnimTest.java70
-rw-r--r--ui-desktop/src/test/java/market/guess/ui/desktop/AppStateTest.java499
-rw-r--r--ui-desktop/src/test/java/market/guess/ui/desktop/ChartsTest.java32
-rw-r--r--ui-desktop/src/test/java/market/guess/ui/desktop/FormatTest.java29
8 files changed, 1430 insertions, 11 deletions
diff --git a/service/src/test/java/market/guess/service/Ex2LoaderTest.java b/service/src/test/java/market/guess/service/Ex2LoaderTest.java
new file mode 100644
index 0000000..d2da8e0
--- /dev/null
+++ b/service/src/test/java/market/guess/service/Ex2LoaderTest.java
@@ -0,0 +1,135 @@
1package market.guess.service;
2
3import static org.junit.jupiter.api.Assertions.assertEquals;
4import static org.junit.jupiter.api.Assertions.assertFalse;
5import static org.junit.jupiter.api.Assertions.assertNotNull;
6import static org.junit.jupiter.api.Assertions.assertTrue;
7
8import java.nio.file.Files;
9import java.nio.file.Path;
10import market.guess.service.catalog.infrastructure.mapper.v2.EventMapperV2;
11import market.guess.service.catalog.infrastructure.provider.v2.XMLLoaderV2;
12import market.guess.service.catalog.infrastructure.provider.v2.XMLValidatorV2;
13import market.guess.service.catalog.infrastructure.repository.InMemoryEventRepository;
14import market.guess.service.catalog.infrastructure.repository.InMemoryUserRepository;
15import org.junit.jupiter.api.BeforeEach;
16import org.junit.jupiter.api.Test;
17import org.junit.jupiter.api.io.TempDir;
18
19class Ex2LoaderTest {
20 private InMemoryEventRepository eventRepo;
21 private InMemoryUserRepository userRepo;
22 private XMLLoaderV2 loader;
23
24 @BeforeEach
25 void setUp() {
26 eventRepo = new InMemoryEventRepository();
27 userRepo = new InMemoryUserRepository();
28 loader = new XMLLoaderV2(eventRepo, userRepo, new EventMapperV2(), new XMLValidatorV2());
29 }
30
31 @Test
32 void testLoadSmallXml() {
33 var path = Path.of("test-data/ex-2/small.xml");
34 var res = loader.load(path);
35 assertTrue(res.isSuccess(), "Loading small.xml should succeed");
36 assertEquals(2, res.getData().eventsLoaded());
37 assertEquals(2, eventRepo.getAll().size());
38 assertEquals(3, userRepo.getAll().size());
39
40 var ev1 = eventRepo.get("1").orElse(null);
41 assertNotNull(ev1);
42 assertEquals("Tikva", ev1.getMarketMaker());
43
44 var ev2 = eventRepo.get("2").orElse(null);
45 assertNotNull(ev2);
46 assertEquals("Avrum", ev2.getMarketMaker());
47
48 var avrum = userRepo.get("Avrum").orElse(null);
49 assertNotNull(avrum);
50 assertEquals(0, avrum.getLedger().getBalance().compareTo(new java.math.BigDecimal("1000.00")));
51 }
52
53 @Test
54 void testLoadMultipleXml() {
55 var path = Path.of("test-data/ex-2/multiple.xml");
56 var res = loader.load(path);
57 assertTrue(res.isSuccess(), "Loading multiple.xml should succeed");
58 assertEquals(4, res.getData().eventsLoaded());
59 assertEquals(4, eventRepo.getAll().size());
60 assertEquals(3, userRepo.getAll().size());
61 }
62
63 @Test
64 void testError2XmlFailsOnZeroInitialCash() {
65 var path = Path.of("test-data/ex-2/error-2.xml");
66 var res = loader.load(path);
67 assertFalse(res.isSuccess(), "error-2.xml must fail validation");
68 assertTrue(
69 res.getMessage().contains("initial cash") || res.getDetails().contains("initial cash"),
70 "Error message should mention initial cash: " + res.getMessage());
71 }
72
73 @Test
74 void testError3XmlFailsOnUnknownEventId() {
75 var path = Path.of("test-data/ex-2/error-3.xml");
76 var res = loader.load(path);
77 assertFalse(res.isSuccess(), "error-3.xml must fail validation");
78 assertTrue(
79 res.getMessage().contains("12") || res.getDetails().contains("12"),
80 "Error message should mention non-existent event ID 12: " + res.getMessage());
81 }
82
83 @Test
84 void testEventWithTwoMarketMakersFails(@TempDir Path dir) throws Exception {
85 // Avrum now also claims event 1, which Tikva already makes; event 2 is left without one.
86 var res = loader.load(smallXmlWith(dir, "<event id=\"2\"/>", "<event id=\"1\"/>"));
87 assertFalse(res.isSuccess());
88 assertTrue(res.getDetails().contains("more than one market maker"), res.getDetails());
89 }
90
91 @Test
92 void testEventWithoutMarketMakerFails(@TempDir Path dir) throws Exception {
93 var res = loader.load(smallXmlWith(dir, "<event id=\"2\"/>", ""));
94 assertFalse(res.isSuccess());
95 assertTrue(res.getDetails().contains("Event ID 2 has no market maker"), res.getDetails());
96 }
97
98 private static Path smallXmlWith(Path dir, String from, String to) throws Exception {
99 var xml = Files.readString(Path.of("test-data/ex-2/small.xml")).replace(from, to);
100 return Files.writeString(dir.resolve("mm.xml"), xml);
101 }
102
103 /** One file per spec validation rule (Ex1 + Ex2); each is small.xml with exactly one defect. */
104 @Test
105 void testEachValidationRuleRejectsItsFile() {
106 String[][] cases = {
107 {"does-not-exist.xml", "FILE_NOT_FOUND"},
108 {"ex1-not-xml.txt", "NOT_XML"},
109 {"ex1-duplicate-event-id.xml", "DUPLICATE_ID"},
110 {"ex1-commission-above-90.xml", "COMMISSION_OUT_OF_RANGE"},
111 {"ex1-commission-below-0.xml", "COMMISSION_OUT_OF_RANGE"},
112 {"ex2-duplicate-user.xml", "DUPLICATE_USER"},
113 {"ex2-zero-initial-cash.xml", "INVALID_INITIAL_CASH"},
114 {"ex2-negative-initial-cash.xml", "INVALID_INITIAL_CASH"},
115 {"ex2-mm-unknown-event.xml", "UNKNOWN_MM_EVENT"},
116 {"ex2-event-without-mm.xml", "MISSING_MM"},
117 {"ex2-event-two-mms.xml", "DUPLICATE_MM"},
118 };
119 for (var c : cases) {
120 assertTrue(loader.load(Path.of("test-data/ex-2/small.xml")).isSuccess());
121 var res = loader.load(Path.of("test-data/ex-2/invalid", c[0]));
122 assertFalse(res.isSuccess(), c[0] + " must be rejected");
123 assertEquals(c[1], res.getMessage(), c[0] + " rejected for the wrong reason: " + res.getDetails());
124 assertEquals(2, eventRepo.getAll().size(), c[0] + " must not replace the loaded file");
125 System.out.println(c[0] + " -> " + res.getDetails());
126 }
127 }
128
129 @Test
130 void testEx1MultipleXmlFailsBecauseV1NotSupported() {
131 var path = Path.of("test-data/ex-1/multiple.xml");
132 var res = loader.load(path);
133 assertFalse(res.isSuccess(), "ex-1 multiple.xml must fail because V1 schema is not supported");
134 }
135}
diff --git a/service/src/test/java/market/guess/service/OrderBookSimulationTest.java b/service/src/test/java/market/guess/service/OrderBookSimulationTest.java
new file mode 100644
index 0000000..a2e8cf3
--- /dev/null
+++ b/service/src/test/java/market/guess/service/OrderBookSimulationTest.java
@@ -0,0 +1,235 @@
1package market.guess.service;
2
3import static org.junit.jupiter.api.Assertions.*;
4
5import java.math.BigDecimal;
6import java.math.RoundingMode;
7import java.util.List;
8import market.guess.model.event.CommissionTiming;
9import market.guess.model.event.CreateEventRequest;
10import market.guess.model.event.MechanismType;
11import market.guess.model.market.OrderBookDTO;
12import market.guess.model.market.PurchaseReceiptDTO;
13import market.guess.service.catalog.LocalCatalogContext;
14import market.guess.service.catalog.infrastructure.MarketContext;
15import market.guess.service.catalog.infrastructure.mapper.v2.EventMapperV2;
16import market.guess.service.catalog.infrastructure.provider.v2.XMLLoaderV2;
17import market.guess.service.catalog.infrastructure.provider.v2.XMLValidatorV2;
18import market.guess.service.catalog.infrastructure.repository.InMemoryEventRepository;
19import market.guess.service.catalog.infrastructure.repository.InMemoryUserRepository;
20import market.guess.service.domain.Event;
21import market.guess.service.domain.User;
22import market.guess.service.fulfillment.LocalFulfillmentContext;
23import market.guess.service.ledger.LedgerContext;
24import market.guess.service.matching.LocalMatchingEngine;
25import market.guess.service.mechanism.OrderBookTradingMechanism;
26import market.guess.service.mechanism.OrderBookTradingMechanism.RestingOrder;
27import market.guess.service.risk.LocalRiskEngine;
28import market.guess.service.settlement.LocalSettlementContext;
29import org.junit.jupiter.api.BeforeEach;
30import org.junit.jupiter.params.ParameterizedTest;
31import org.junit.jupiter.params.provider.EnumSource;
32
33/**
34 * Replays the "Will it rain tomorrow?" order-book walkthrough (clob_simulation.html) step by step,
35 * once per commission timing, checking books, holdings, the pool and every trader's cash.
36 */
37public class OrderBookSimulationTest {
38 private static final int YES = 0;
39 private static final int NO = 1;
40
41 private InMemoryEventRepository eventRepo;
42 private InMemoryUserRepository userRepo;
43 private LocalCatalogContext catalog;
44 private LocalGuessMarketContext market;
45
46 @BeforeEach
47 public void setUp() {
48 eventRepo = new InMemoryEventRepository();
49 userRepo = new InMemoryUserRepository();
50 var context = new MarketContext(eventRepo, userRepo);
51 var loader = new XMLLoaderV2(eventRepo, userRepo, new EventMapperV2(), new XMLValidatorV2());
52 catalog = new LocalCatalogContext(loader, context, eventRepo);
53 var ledger = new LedgerContext(userRepo, java.time.Clock.systemUTC());
54 market =
55 new LocalGuessMarketContext(
56 context,
57 new LocalRiskEngine(eventRepo),
58 new LocalMatchingEngine(java.time.Clock.systemUTC()),
59 new LocalFulfillmentContext(ledger),
60 new LocalSettlementContext(ledger));
61
62 userRepo.add(new User("Zoe", 500));
63 userRepo.add(new User("Alice", 200));
64 userRepo.add(new User("Bob", 200));
65 userRepo.add(new User("Carol", 200));
66 }
67
68 @ParameterizedTest(name = "commission {0}")
69 @EnumSource(value = CommissionTiming.class, names = {"ON_PURCHASE", "ON_CLOSE"})
70 public void willItRainTomorrow(CommissionTiming timing) {
71 boolean onPurchase = timing == CommissionTiming.ON_PURCHASE;
72 var created =
73 catalog.createEvent(
74 new CreateEventRequest(
75 "Will it rain tomorrow?", "", MechanismType.ORDER_BOOK, 1, timing, "Zoe",
76 null, BigDecimal.ONE, List.of("YES", "NO"), true));
77 assertTrue(created.isSuccess(), created.getMessage());
78
79 // 1. Market opens. 2. Zoe mints the first pairs: $100 into the pool, 100 YES + 100 NO, no quotes.
80 assertTrue(catalog.openEvent("1").isSuccess());
81 assertCash("Zoe", "400.00");
82 assertPool("100.00");
83 assertHolds("Zoe", 100, 100);
84 assertBook(YES, List.of(), List.of());
85 assertBook(NO, List.of(), List.of());
86
87 // 3. Bob bids on YES. 4. Carol bids on YES too.
88 place("Bob", "YES", "BUY", "0.50", 20);
89 place("Carol", "YES", "BUY", "0.48", 15);
90 // 5. Zoe quotes a YES ask. 6. Zoe adds a deeper ask.
91 place("Zoe", "YES", "SELL", "0.58", 25);
92 assertEquals("$0.08", orderBook(YES).spread());
93 assertEquals("0.54", book().prices()[YES].toPlainString()); // mid-price
94 place("Zoe", "YES", "SELL", "0.65", 15);
95 assertBook(YES, List.of("Bob 20@0.50", "Carol 15@0.48"), List.of("Zoe 25@0.58", "Zoe 15@0.65"));
96
97 // 7. Alice's order arrives. 8. Matched: a resale — Zoe is paid, the pool doesn't move.
98 var receipt = place("Alice", "YES", "BUY", "0.58", 25);
99 assertEquals("14.50", receipt.sharesCost());
100 assertCash("Alice", onPurchase ? "185.36" : "185.50"); // 1% of 14.50 = 0.145 -> 0.14
101 assertCash("Zoe", onPurchase ? "414.64" : "414.50");
102 assertPool("100.00");
103 assertBook(YES, List.of("Bob 20@0.50", "Carol 15@0.48"), List.of("Zoe 15@0.65"));
104 assertEquals("$0.15", orderBook(YES).spread()); // the cheap level is gone, spread widens
105 assertEquals("0.58", lastTrade(YES));
106
107 // 9. Zoe quotes a NO ask. 10. Bob's order arrives. 11. Matched: a partial fill of Zoe's 50.
108 place("Zoe", "NO", "SELL", "0.45", 50);
109 receipt = place("Bob", "NO", "BUY", "0.45", 25);
110 assertEquals("11.25", receipt.sharesCost());
111 assertBook(NO, List.of(), List.of("Zoe 25@0.45"));
112 assertCash("Bob", onPurchase ? "188.64" : "188.75");
113 assertEquals("0.45", lastTrade(NO));
114
115 // 12. Zoe's order arrives: sell 30 YES at 0.45 or better.
116 // 13. Matched: 20 at Bob's 0.50, then 10 at Carol's 0.48. (Self-trade prevention keeps it
117 // from merging against Zoe's own NO ask, which would otherwise pay 0.55.)
118 receipt = place("Zoe", "YES", "SELL", "0.45", 30);
119 assertEquals(2, receipt.trades().size());
120 assertEquals("14.80", receipt.sharesCost());
121 assertBook(YES, List.of("Carol 5@0.48"), List.of("Zoe 15@0.65"));
122 assertEquals("0.48", lastTrade(YES));
123 assertCash("Zoe", onPurchase ? "440.95" : "440.55");
124 assertCash("Bob", onPurchase ? "178.54" : "178.75");
125 assertCash("Carol", onPurchase ? "195.15" : "195.20");
126 assertHolds("Zoe", 45, 75);
127
128 // 14. Carol bids on NO below Zoe's 0.45 ask, so it rests.
129 place("Carol", "NO", "BUY", "0.42", 35);
130 assertBook(NO, List.of("Carol 35@0.42"), List.of("Zoe 25@0.45"));
131
132 // 15. Alice's order arrives: buy 40 YES at 0.62. No ask is that cheap, but 0.62 + 0.42 >= $1.
133 // 16. Matched: a peer-to-peer mint of min(40, 35) = 35 pairs. Carol pays her 0.42, Alice the
134 // remaining 0.58, both into the pool; Alice's other 5 rest at her own price.
135 receipt = place("Alice", "YES", "BUY", "0.62", 40);
136 assertEquals("35.00", receipt.sharesCost());
137 assertPool("135.00");
138 assertBook(YES, List.of("Alice 5@0.62", "Carol 5@0.48"), List.of("Zoe 15@0.65"));
139 assertBook(NO, List.of(), List.of("Zoe 25@0.45"));
140 assertEquals("0.58", lastTrade(YES));
141 assertEquals("0.42", lastTrade(NO));
142 assertHolds("Alice", 60, 0);
143 assertHolds("Carol", 10, 35);
144 assertCash("Alice", onPurchase ? "164.86" : "165.20");
145 assertCash("Carol", onPurchase ? "180.30" : "180.50");
146
147 // 17. An order gets rejected before it reaches the book.
148 var rejected = market.placeOrder("Bob", "1", "YES", "BUY", new BigDecimal("1.05"), 10);
149 assertFalse(rejected.isSuccess());
150 assertEquals("Price must be between $0.01 and $0.99.", rejected.getMessage());
151 assertBook(YES, List.of("Alice 5@0.62", "Carol 5@0.48"), List.of("Zoe 15@0.65"));
152
153 // 18. Bob asks 0.15 for his 25 NO. The walkthrough predates merging and leaves this unmatched,
154 // but 0.15 + Zoe's 0.65 YES ask <= $1, so 15 pairs merge back into the pool: Zoe is paid her
155 // 0.65, Bob the remaining 0.35. His other 10 NO rest, still with no buyer.
156 receipt = place("Bob", "NO", "SELL", "0.15", 25);
157 assertEquals("15.00", receipt.sharesCost()); // 15*0.35 + 15*0.65
158 assertPool("120.00");
159 assertBook(YES, List.of("Alice 5@0.62", "Carol 5@0.48"), List.of());
160 assertBook(NO, List.of(), List.of("Bob 10@0.15", "Zoe 25@0.45"));
161 assertHolds("Zoe", 30, 75);
162 assertHolds("Bob", 20, 10);
163 assertCash("Bob", onPurchase ? "183.79" : "184.00");
164 assertCash("Zoe", onPurchase ? "451.05" : "450.30");
165
166 // 19. Resolution: YES wins. Each of the 120 YES shares pays $1 (less 1% on close, to Zoe),
167 // every resting order is cancelled and the pool is emptied.
168 assertTrue(market.settleEvent("Zoe", "1", "YES").isSuccess());
169 assertPool("0.00");
170 assertBook(YES, List.of(), List.of());
171 assertBook(NO, List.of(), List.of());
172 assertCash("Zoe", onPurchase ? "481.05" : "481.20");
173 assertCash("Alice", onPurchase ? "224.86" : "224.60");
174 assertCash("Bob", onPurchase ? "203.79" : "203.80");
175 assertCash("Carol", onPurchase ? "190.30" : "190.40");
176 assertEquals(onPurchase ? "0.75" : "1.20", event().getCommission().toPlainString());
177
178 // Money is conserved: commission only moves cash to Zoe sooner or later.
179 var total =
180 userRepo.getAll().stream()
181 .map(u -> u.getLedger().getBalance())
182 .reduce(BigDecimal.ZERO, BigDecimal::add);
183 assertEquals("1100.00", money(total));
184 }
185
186 private PurchaseReceiptDTO place(String user, String option, String side, String price, long qty) {
187 var res = market.placeOrder(user, "1", option, side, new BigDecimal(price), qty);
188 assertTrue(res.isSuccess(), user + " " + side + " " + qty + " " + option + " @ " + price + ": " + res.getMessage());
189 return res.getData();
190 }
191
192 private Event event() {
193 return eventRepo.get("1").orElseThrow();
194 }
195
196 private OrderBookTradingMechanism book() {
197 return (OrderBookTradingMechanism) event().getMechanism();
198 }
199
200 private OrderBookDTO orderBook(int option) {
201 return catalog.getEvent("1").getData().orderBooks().get(option);
202 }
203
204 private String lastTrade(int option) {
205 return book().getLastTradePrice(option).toPlainString();
206 }
207
208 private void assertBook(int option, List<String> bids, List<String> asks) {
209 assertEquals(bids, levels(book().getBids().get(option)), "bids");
210 assertEquals(asks, levels(book().getAsks().get(option)), "asks");
211 }
212
213 private static List<String> levels(List<RestingOrder> orders) {
214 return orders.stream()
215 .map(o -> o.getUser() + " " + o.getQuantity() + "@" + o.getPrice().toPlainString())
216 .toList();
217 }
218
219 private void assertCash(String user, String expected) {
220 assertEquals(expected, money(userRepo.get(user).orElseThrow().getLedger().getBalance()), user + " cash");
221 }
222
223 private void assertPool(String expected) {
224 assertEquals(expected, money(event().getLedger().getBalance()), "pool");
225 }
226
227 private void assertHolds(String user, long yes, long no) {
228 assertEquals(yes, event().getSharesHeldBy(user, "YES"), user + " YES");
229 assertEquals(no, event().getSharesHeldBy(user, "NO"), user + " NO");
230 }
231
232 private static String money(BigDecimal value) {
233 return value.setScale(2, RoundingMode.HALF_EVEN).toPlainString();
234 }
235}
diff --git a/service/src/test/java/market/guess/service/OrderBookTradingTest.java b/service/src/test/java/market/guess/service/OrderBookTradingTest.java
new file mode 100644
index 0000000..0469de6
--- /dev/null
+++ b/service/src/test/java/market/guess/service/OrderBookTradingTest.java
@@ -0,0 +1,416 @@
1package market.guess.service;
2
3import static org.junit.jupiter.api.Assertions.*;
4
5import java.math.BigDecimal;
6import java.nio.file.Path;
7import market.guess.service.catalog.LocalCatalogContext;
8import market.guess.service.catalog.infrastructure.MarketContext;
9import market.guess.service.catalog.infrastructure.mapper.v2.EventMapperV2;
10import market.guess.service.catalog.infrastructure.provider.v2.XMLLoaderV2;
11import market.guess.service.catalog.infrastructure.provider.v2.XMLValidatorV2;
12import market.guess.service.catalog.infrastructure.repository.InMemoryEventRepository;
13import market.guess.service.catalog.infrastructure.repository.InMemoryUserRepository;
14import market.guess.service.fulfillment.LocalFulfillmentContext;
15import market.guess.service.ledger.LedgerContext;
16import market.guess.service.matching.LocalMatchingEngine;
17import market.guess.service.mechanism.OrderBookTradingMechanism;
18import market.guess.service.risk.LocalRiskEngine;
19import market.guess.service.settlement.LocalSettlementContext;
20import org.junit.jupiter.api.BeforeEach;
21import org.junit.jupiter.api.Test;
22
23public class OrderBookTradingTest {
24
25 private InMemoryEventRepository eventRepo;
26 private InMemoryUserRepository userRepo;
27 private MarketContext marketContext;
28 private LocalCatalogContext catalog;
29 private LocalGuessMarketContext guessMarket;
30 private LedgerContext ledgerContext;
31
32 @BeforeEach
33 public void setUp() {
34 eventRepo = new InMemoryEventRepository();
35 userRepo = new InMemoryUserRepository();
36 marketContext = new MarketContext(eventRepo, userRepo);
37 var loader = new XMLLoaderV2(eventRepo, userRepo, new EventMapperV2(), new XMLValidatorV2());
38 catalog = new LocalCatalogContext(loader, marketContext, eventRepo);
39 ledgerContext = new LedgerContext(userRepo, java.time.Clock.systemUTC());
40 var settlementContext = new LocalSettlementContext(ledgerContext);
41 guessMarket =
42 new LocalGuessMarketContext(
43 marketContext,
44 new LocalRiskEngine(eventRepo),
45 new LocalMatchingEngine(java.time.Clock.systemUTC()),
46 new LocalFulfillmentContext(ledgerContext),
47 settlementContext);
48
49 // Load small.xml from ex-2
50 var loadRes = catalog.loadEvents(Path.of("test-data/ex-2/small.xml"));
51 assertTrue(loadRes.isSuccess(), "Loading small.xml should succeed");
52 }
53
54 /** LMSR: the subsidy is paid once, and closing empties the contract back into user accounts. */
55 @Test
56 public void testLmsrSettlementConservesMoney() {
57 var totalBefore = totalUserCash();
58
59 assertTrue(catalog.openEvent("1").isSuccess());
60 var contract = eventRepo.get("1").orElseThrow().getLedger();
61 var tikva = userRepo.get("Tikva").orElseThrow().getLedger();
62 // Contract holds exactly what Tikva paid, not a second, self-seeded copy.
63 assertEquals(0, new BigDecimal("10000").subtract(tikva.getBalance()).compareTo(contract.getBalance()));
64
65 var buy = guessMarket.placeOrder("Menash", "1", "1:0", null, 10);
66 assertTrue(buy.isSuccess(), buy.getMessage());
67 assertEquals(0, totalBefore.compareTo(totalUserCash().add(contract.getBalance())));
68
69 assertTrue(guessMarket.settleEvent("Tikva", "1", "1:0").isSuccess());
70 assertEquals(0, contract.getBalance().signum(), "contract must be empty after close");
71 assertEquals(0, totalBefore.compareTo(totalUserCash()), "no money created or lost");
72 }
73
74 private BigDecimal totalUserCash() {
75 var total = BigDecimal.ZERO;
76 for (var u : userRepo.getAll()) total = total.add(u.getLedger().getBalance());
77 return total;
78 }
79
80 /** Avrum (the MM) quotes his minted Argentina shares, which opening the event no longer does. */
81 private void avrumAsks100At50() {
82 var res = guessMarket.placeOrder("Avrum", "2", "2:0", "SELL", new BigDecimal("0.50"), 100);
83 assertTrue(res.isSuccess(), res.getMessage());
84 }
85
86 @Test
87 public void testOpeningMintsWithoutQuoting() {
88 var avrum = userRepo.get("Avrum").orElseThrow();
89 var before = avrum.getLedger().getBalance();
90
91 var openRes = catalog.openEvent("2");
92 assertTrue(openRes.isSuccess());
93
94 // $100 in, 100 pairs out, nothing on the books until Avrum quotes.
95 assertEquals(0, before.subtract(new BigDecimal("100")).compareTo(avrum.getLedger().getBalance()));
96 var event = eventRepo.get("2").orElseThrow();
97 assertEquals(100, event.getSharesHeldBy("Avrum", "2:0"));
98 assertEquals(100, event.getSharesHeldBy("Avrum", "2:1"));
99
100 var detail = catalog.getEvent("2").getData();
101 assertEquals(2, detail.orderBooks().size());
102 var obArg = detail.orderBooks().get(0);
103 assertEquals("2:0", obArg.optionKey());
104 assertEquals("Argentina", obArg.optionName());
105 assertNull(obArg.bestBid());
106 assertNull(obArg.bestAsk());
107 assertTrue(obArg.orders().isEmpty());
108 }
109
110 @Test
111 public void testBuyMatchingRestingAsk() {
112 catalog.openEvent("2");
113 avrumAsks100At50();
114
115 var menash = userRepo.get("Menash").orElseThrow();
116 var avrum = userRepo.get("Avrum").orElseThrow();
117 var initialMenashBal = menash.getLedger().getBalance();
118 var initialAvrumBal = avrum.getLedger().getBalance();
119
120 // Menash buys 10 shares of Argentina @ 0.50
121 var buyRes =
122 guessMarket.placeOrder(
123 "Menash", "2", "2:0", "BUY", new BigDecimal("0.50"), 10);
124 assertTrue(buyRes.isSuccess(), "Buy order should succeed: " + buyRes.getMessage());
125
126 var receipt = buyRes.getData();
127 assertEquals(1, receipt.trades().size());
128 assertEquals("5.00", receipt.sharesCost());
129
130 // Menash paid $5.00, Avrum received $5.00
131 assertEquals(0, initialMenashBal.subtract(new BigDecimal("5.00")).compareTo(menash.getLedger().getBalance()));
132 assertEquals(0, initialAvrumBal.add(new BigDecimal("5.00")).compareTo(avrum.getLedger().getBalance()));
133
134 // Order book remaining ask should now be 90 shares @ 0.50
135 var detail = catalog.getEvent("2").getData();
136 var obArg = detail.orderBooks().get(0);
137 assertEquals(1, obArg.orders().size());
138 assertEquals(90, obArg.orders().get(0).quantity());
139 }
140
141 @Test
142 public void testBuyRestingInLadderWhenNoMatchingAsk() {
143 catalog.openEvent("2");
144 avrumAsks100At50();
145
146 // Menash places BUY order at 0.40 (below MM ask of 0.50)
147 var buyRes =
148 guessMarket.placeOrder(
149 "Menash", "2", "2:0", "BUY", new BigDecimal("0.40"), 20);
150 assertTrue(buyRes.isSuccess());
151
152 var receipt = buyRes.getData();
153 // No immediate trades executed
154 assertEquals(0, receipt.trades().size());
155
156 // Order book should now have a BID and an ASK
157 var detail = catalog.getEvent("2").getData();
158 var obArg = detail.orderBooks().get(0);
159 assertEquals("$0.40", obArg.bestBid());
160 assertEquals("$0.50", obArg.bestAsk());
161 assertEquals("$0.10", obArg.spread());
162 assertEquals(2, obArg.orders().size());
163
164 var bidOrder = obArg.orders().stream().filter(o -> "BID".equals(o.side())).findFirst().orElseThrow();
165 assertEquals("Menash", bidOrder.user());
166 assertEquals(20, bidOrder.quantity());
167 assertEquals("$0.40", bidOrder.price());
168 }
169
170 @Test
171 public void testSellMatchingRestingBid() {
172 catalog.openEvent("2");
173
174 // 1. Menash places a resting bid: BUY 15 @ 0.45
175 var bidRes =
176 guessMarket.placeOrder(
177 "Menash", "2", "2:0", "BUY", new BigDecimal("0.45"), 15);
178 assertTrue(bidRes.isSuccess());
179
180 // 2. Avrum (who holds initial 100 shares of Argentina) sells 10 shares @ 0.45
181 var initialAvrumBal = userRepo.get("Avrum").orElseThrow().getLedger().getBalance();
182 var initialMenashBal = userRepo.get("Menash").orElseThrow().getLedger().getBalance();
183
184 var sellRes =
185 guessMarket.placeOrder(
186 "Avrum", "2", "2:0", "SELL", new BigDecimal("0.45"), 10);
187 assertTrue(sellRes.isSuccess(), "Sell order should succeed: " + sellRes.getMessage());
188
189 var receipt = sellRes.getData();
190 assertEquals(1, receipt.trades().size());
191 // 10 shares @ 0.45 = 4.50
192 assertEquals("4.50", receipt.sharesCost());
193
194 // Avrum received 4.50, Menash paid 4.50
195 var currentAvrumBal = userRepo.get("Avrum").orElseThrow().getLedger().getBalance();
196 var currentMenashBal = userRepo.get("Menash").orElseThrow().getLedger().getBalance();
197 assertEquals(0, initialAvrumBal.add(new BigDecimal("4.50")).compareTo(currentAvrumBal));
198 assertEquals(0, initialMenashBal.subtract(new BigDecimal("4.50")).compareTo(currentMenashBal));
199
200 // Remaining resting bid should have 5 shares left
201 var detail = catalog.getEvent("2").getData();
202 var obArg = detail.orderBooks().get(0);
203 var remainingBid = obArg.orders().stream().filter(o -> "BID".equals(o.side())).findFirst().orElseThrow();
204 assertEquals(5, remainingBid.quantity());
205 }
206
207 @Test
208 public void testSellRestingInLadder() {
209 catalog.openEvent("2");
210 avrumAsks100At50();
211
212 // Menash first buys 10 shares @ 0.50 from Avrum
213 guessMarket.placeOrder("Menash", "2", "2:0", "BUY", new BigDecimal("0.50"), 10);
214
215 // Menash now owns 10 shares. Menash places a SELL order for 5 shares @ 0.60 (higher than existing ask 0.50)
216 var sellRes =
217 guessMarket.placeOrder(
218 "Menash", "2", "2:0", "SELL", new BigDecimal("0.60"), 5);
219 assertTrue(sellRes.isSuccess());
220
221 var detail = catalog.getEvent("2").getData();
222 var obArg = detail.orderBooks().get(0);
223
224 // The asks should contain Avrum's 90 @ 0.50 and Menash's 5 @ 0.60
225 var menashAsk =
226 obArg.orders().stream()
227 .filter(o -> "Menash".equals(o.user()) && "ASK".equals(o.side()))
228 .findFirst()
229 .orElse(null);
230 assertNotNull(menashAsk);
231 assertEquals(5, menashAsk.quantity());
232 assertEquals("$0.60", menashAsk.price());
233 }
234
235 @Test
236 public void testRiskEngineBlocksUnauthorizedSell() {
237 catalog.openEvent("2");
238
239 // Menash currently holds 0 shares of Argentina. Attempting to sell must be rejected by risk engine.
240 var sellRes =
241 guessMarket.placeOrder(
242 "Menash", "2", "2:0", "SELL", new BigDecimal("0.50"), 5);
243 assertFalse(sellRes.isSuccess(), "Sell should be rejected when user holds insufficient shares");
244 assertTrue(sellRes.getMessage().contains("Insufficient shares"));
245 }
246
247 @Test
248 public void testRiskEngineBlocksInsufficientFundsBuy() {
249 catalog.openEvent("2");
250
251 // Menash initial cash is 100. Attempting to buy 500 shares @ 0.50 ($250) must fail.
252 var buyRes =
253 guessMarket.placeOrder(
254 "Menash", "2", "2:0", "BUY", new BigDecimal("0.50"), 500);
255 assertFalse(buyRes.isSuccess(), "Buy should be rejected when user has insufficient funds");
256 assertTrue(buyRes.getMessage().contains("Insufficient balance"));
257 }
258
259 @Test
260 public void testRestingOrdersReserveCashAndShares() {
261 catalog.openEvent("2");
262
263 // Menash has $100: a resting $90 bid leaves only $10 for anything else.
264 assertTrue(guessMarket.placeOrder("Menash", "2", "2:0", "BUY", new BigDecimal("0.60"), 150).isSuccess());
265 var overBudget = guessMarket.placeOrder("Menash", "2", "2:1", "BUY", new BigDecimal("0.60"), 20);
266 assertFalse(overBudget.isSuccess());
267 assertTrue(overBudget.getMessage().contains("Insufficient balance"));
268
269 // Avrum holds 100 Argentina: 60 already offered leaves 40 to offer again.
270 assertTrue(guessMarket.placeOrder("Avrum", "2", "2:0", "SELL", new BigDecimal("0.70"), 60).isSuccess());
271 var overSold = guessMarket.placeOrder("Avrum", "2", "2:0", "SELL", new BigDecimal("0.80"), 50);
272 assertFalse(overSold.isSuccess());
273 assertTrue(overSold.getMessage().contains("Insufficient shares"));
274 }
275
276 @Test
277 public void testNoSelfTrade() {
278 catalog.openEvent("2");
279 assertTrue(guessMarket.placeOrder("Avrum", "2", "2:0", "SELL", new BigDecimal("0.55"), 10).isSuccess());
280 var res = guessMarket.placeOrder("Avrum", "2", "2:0", "BUY", new BigDecimal("0.60"), 10);
281 assertTrue(res.isSuccess());
282 assertEquals(0, res.getData().trades().size());
283 assertEquals(2, catalog.getEvent("2").getData().orderBooks().get(0).orders().size());
284 }
285
286 @Test
287 public void testOrderBookParticipantsUpdatingOnTrades() {
288 // 1. Initial draft: participants contains Avrum with 0 shares, $0.00 value
289 var draftDetail = catalog.getEvent("2").getData();
290 assertNotNull(draftDetail.participants());
291 assertEquals(1, draftDetail.participants().size());
292 var draftAvrum = draftDetail.participants().get(0);
293 assertEquals("Avrum", draftAvrum.user());
294 assertEquals("MM", draftAvrum.tag());
295 assertEquals(0, draftAvrum.yes());
296 assertEquals(0, draftAvrum.no());
297 assertEquals("$0.00", draftAvrum.value());
298
299 // 2. Open event: Avrum minted 100 YES, 100 NO, value $100.00 (100*0.50 + 100*0.50)
300 catalog.openEvent("2");
301 var openDetail = catalog.getEvent("2").getData();
302 assertEquals(1, openDetail.participants().size());
303 var openAvrum = openDetail.participants().get(0);
304 assertEquals("Avrum", openAvrum.user());
305 assertEquals("MM", openAvrum.tag());
306 assertEquals(100, openAvrum.yes());
307 assertEquals(100, openAvrum.no());
308 assertEquals("$100.00", openAvrum.value());
309
310 // 3. Avrum quotes, Menash buys 10 YES shares (Argentina) @ 0.50
311 avrumAsks100At50();
312 var buyRes =
313 guessMarket.placeOrder("Menash", "2", "2:0", "BUY", new BigDecimal("0.50"), 10);
314 assertTrue(buyRes.isSuccess());
315
316 var afterBuyDetail = catalog.getEvent("2").getData();
317 assertEquals(2, afterBuyDetail.participants().size());
318
319 var avrumAfterBuy =
320 afterBuyDetail.participants().stream()
321 .filter(p -> "Avrum".equals(p.user()))
322 .findFirst()
323 .orElseThrow();
324 assertEquals("MM", avrumAfterBuy.tag());
325 assertEquals(90, avrumAfterBuy.yes());
326 assertEquals(100, avrumAfterBuy.no());
327 assertEquals("$95.00", avrumAfterBuy.value());
328
329 var menashAfterBuy =
330 afterBuyDetail.participants().stream()
331 .filter(p -> "Menash".equals(p.user()))
332 .findFirst()
333 .orElseThrow();
334 assertEquals("", menashAfterBuy.tag());
335 assertEquals(10, menashAfterBuy.yes());
336 assertEquals(0, menashAfterBuy.no());
337 assertEquals("$5.00", menashAfterBuy.value());
338
339 // 4. Avrum places a resting BID for 10 @ 0.40 (below 0.50 ask), and Menash sells 4 YES shares @ 0.40
340 var avrumBid =
341 guessMarket.placeOrder("Avrum", "2", "2:0", "BUY", new BigDecimal("0.40"), 10);
342 assertTrue(avrumBid.isSuccess());
343 var menashSell =
344 guessMarket.placeOrder("Menash", "2", "2:0", "SELL", new BigDecimal("0.40"), 4);
345 assertTrue(menashSell.isSuccess());
346
347 var afterSellDetail = catalog.getEvent("2").getData();
348 var avrumAfterSell =
349 afterSellDetail.participants().stream()
350 .filter(p -> "Avrum".equals(p.user()))
351 .findFirst()
352 .orElseThrow();
353 assertEquals(94, avrumAfterSell.yes());
354 assertEquals(100, avrumAfterSell.no());
355 assertEquals("$92.30", avrumAfterSell.value());
356
357 var menashAfterSell =
358 afterSellDetail.participants().stream()
359 .filter(p -> "Menash".equals(p.user()))
360 .findFirst()
361 .orElseThrow();
362 assertEquals(6, menashAfterSell.yes());
363 assertEquals(0, menashAfterSell.no());
364 assertEquals("$2.70", menashAfterSell.value());
365 }
366
367 @Test
368 public void testSellWalksBidsAndLastTradeIsLastFill() {
369 catalog.openEvent("2");
370 avrumAsks100At50();
371 guessMarket.placeOrder("Tikva", "2", "2:0", "BUY", new BigDecimal("0.50"), 100); // Tikva owns the YES
372 guessMarket.placeOrder("Menash", "2", "2:0", "BUY", new BigDecimal("0.45"), 20);
373 guessMarket.placeOrder("Avrum", "2", "2:0", "BUY", new BigDecimal("0.44"), 15);
374
375 var res = guessMarket.placeOrder("Tikva", "2", "2:0", "SELL", new BigDecimal("0.40"), 30);
376 assertTrue(res.isSuccess(), res.getMessage());
377 assertEquals(2, res.getData().trades().size());
378 assertEquals("13.40", res.getData().sharesCost()); // 20*0.45 + 10*0.44
379
380 var ob = (OrderBookTradingMechanism) eventRepo.get("2").orElseThrow().getMechanism();
381 assertEquals(5, ob.getBids().get(0).get(0).getQuantity());
382 assertEquals("0.44", ob.getLastTradePrice(0).toPlainString()); // last fill, not the 0.4467 average
383 }
384
385 @Test
386 public void testPriceMustBeStrictlyInsideZeroAndD() {
387 catalog.openEvent("2");
388 for (var price : new String[] {"1.00", "0.00"}) {
389 var res = guessMarket.placeOrder("Menash", "2", "2:0", "BUY", new BigDecimal(price), 1);
390 assertFalse(res.isSuccess());
391 assertEquals("Price must be between $0.01 and $0.99.", res.getMessage());
392 }
393 }
394
395 @Test
396 public void testResolutionCancelsRestingOrdersAndPaysHolders() {
397 catalog.openEvent("2");
398 avrumAsks100At50();
399 guessMarket.placeOrder("Menash", "2", "2:0", "BUY", new BigDecimal("0.50"), 20);
400 guessMarket.placeOrder("Tikva", "2", "2:0", "BUY", new BigDecimal("0.40"), 10);
401 // Menash resells 5 to Tikva: only Tikva should be paid for those.
402 guessMarket.placeOrder("Menash", "2", "2:0", "SELL", new BigDecimal("0.40"), 5);
403
404 var menash = userRepo.get("Menash").orElseThrow();
405 var menashBal = menash.getLedger().getBalance();
406 assertTrue(guessMarket.settleEvent("Avrum", "2", "2:0").isSuccess());
407
408 for (var book : catalog.getEvent("2").getData().orderBooks()) {
409 assertTrue(book.orders().isEmpty());
410 }
411 // 15 shares held * $1, less 15% on-close commission.
412 assertEquals(0, menashBal.add(new BigDecimal("12.75")).compareTo(menash.getLedger().getBalance()));
413 // Pool held exactly the 100 minted pairs, and all 100 winning shares were paid out.
414 assertEquals(0, eventRepo.get("2").orElseThrow().getLedger().getBalance().signum());
415 }
416}
diff --git a/service/src/test/java/market/guess/service/catalog/infrastructure/adapter/LedgerMapperTest.java b/service/src/test/java/market/guess/service/catalog/infrastructure/adapter/LedgerMapperTest.java
index 976442c..6e16cee 100644
--- a/service/src/test/java/market/guess/service/catalog/infrastructure/adapter/LedgerMapperTest.java
+++ b/service/src/test/java/market/guess/service/catalog/infrastructure/adapter/LedgerMapperTest.java
@@ -11,6 +11,7 @@ import java.util.List;
11import market.guess.model.ledger.LedgerDTO; 11import market.guess.model.ledger.LedgerDTO;
12import market.guess.model.ledger.LedgerEntryDTO; 12import market.guess.model.ledger.LedgerEntryDTO;
13import market.guess.model.ledger.LedgerType; 13import market.guess.model.ledger.LedgerType;
14import market.guess.service.helpers.InstantOptions;
14import market.guess.service.ledger.Ledger; 15import market.guess.service.ledger.Ledger;
15import market.guess.service.ledger.LedgerEntry; 16import market.guess.service.ledger.LedgerEntry;
16import org.junit.Test; 17import org.junit.Test;
@@ -90,24 +91,26 @@ public class LedgerMapperTest {
90 assertNull(entry.balanceAfter()); 91 assertNull(entry.balanceAfter());
91 } 92 }
92 93
93 /**
94 * LedgerEntry.id/time and Ledger's account-blocked state have no matching AccountDTO/
95 * LedgerEntryDTO source property, so MapStruct leaves them at their Java defaults. This pins that
96 * known gap; once the mapper is taught to fill seq/at/blocked, update these assertions instead of
97 * deleting them.
98 */
99 @Test 94 @Test
100 public void unmappedTargetPropertiesFallBackToDefaults() { 95 public void mapsSeqAndAtFromEntryIdAndTime() {
96 Instant time = Instant.parse("2026-01-03T10:15:30Z");
101 Ledger ledger = new Ledger("dave", new BigDecimal("0")); 97 Ledger ledger = new Ledger("dave", new BigDecimal("0"));
102 ledger.record( 98 ledger.record(
103 new LedgerEntry( 99 new LedgerEntry(7, time, LedgerType.COMMISSION, BigDecimal.ONE, BigDecimal.ONE, "n"));
104 7, Instant.now(), LedgerType.COMMISSION, BigDecimal.ONE, BigDecimal.ONE, "n"));
105 100
106 LedgerDTO dto = mapper.ledgerToLedgerDTO(ledger); 101 LedgerDTO dto = mapper.ledgerToLedgerDTO(ledger);
107 102
108 assertFalse(dto.blocked()); 103 assertFalse(dto.blocked());
109 LedgerEntryDTO entry = dto.entries().get(0); 104 LedgerEntryDTO entry = dto.entries().get(0);
110 assertEquals(0L, entry.seq()); 105 assertEquals(7L, entry.seq());
111 assertNull(entry.at()); 106 assertEquals(InstantOptions.humanize(time), entry.at());
107 }
108
109 @Test
110 public void nullEntryTimeMapsToNullAt() {
111 Ledger ledger = new Ledger("erin", new BigDecimal("0"));
112 ledger.record(new LedgerEntry(1, null, LedgerType.REFUND, BigDecimal.ONE, BigDecimal.ONE, "n"));
113
114 assertNull(mapper.ledgerToLedgerDTO(ledger).entries().get(0).at());
112 } 115 }
113} 116}
diff --git a/ui-desktop/src/test/java/market/guess/ui/desktop/AnimTest.java b/ui-desktop/src/test/java/market/guess/ui/desktop/AnimTest.java
new file mode 100644
index 0000000..1a39ba1
--- /dev/null
+++ b/ui-desktop/src/test/java/market/guess/ui/desktop/AnimTest.java
@@ -0,0 +1,70 @@
1package market.guess.ui.desktop;
2
3import static org.junit.jupiter.api.Assertions.assertEquals;
4
5import market.guess.ui.desktop.components.graphic.GraphicHelper;
6import market.guess.ui.desktop.components.graphic.MarketChartGraphic;
7import market.guess.ui.desktop.components.graphic.Motion;
8import market.guess.ui.desktop.components.graphic.SparklineGraphic;
9import org.junit.jupiter.api.Test;
10
11/**
12 * The handoff requires "animations off" to show the frozen final frame, fully legible and never
13 * blank. Since every graphic renders as a function of progress, that contract is exactly: at
14 * progress 1, every driver lands on its designed end value.
15 */
16class AnimTest {
17 private static final double[] BAR1 = {0, .35, .30, 1, .60, .6, 1, .35};
18
19 @Test
20 void keyframesHoldTheirEndValue() {
21 assertEquals(.35, GraphicHelper.at(0, BAR1), 1e-9);
22 assertEquals(1.0, GraphicHelper.at(.30, BAR1), 1e-9);
23 assertEquals(.35, GraphicHelper.at(1, BAR1), 1e-9);
24 assertEquals(.675, GraphicHelper.at(.15, BAR1), 1e-9); // interpolates linearly between stops
25 assertEquals(.35, Motion.at(0, BAR1), 1e-9);
26 }
27
28 @Test
29 void ridersEndOnTheLastPointOfTheirPath() {
30 assertEquals(
31 MarketChartGraphic.YES_CURVE[6],
32 MarketChartGraphic.rideCurve(MarketChartGraphic.YES_CURVE, 1)[0],
33 1e-6);
34 assertEquals(
35 MarketChartGraphic.YES_CURVE[7],
36 MarketChartGraphic.rideCurve(MarketChartGraphic.YES_CURVE, 1)[1],
37 1e-6);
38
39 int last = SparklineGraphic.SPARK.length;
40 assertEquals(
41 SparklineGraphic.SPARK[last - 2],
42 SparklineGraphic.ridePolyline(SparklineGraphic.SPARK, 1)[0],
43 1e-6);
44 assertEquals(
45 SparklineGraphic.SPARK[last - 1],
46 SparklineGraphic.ridePolyline(SparklineGraphic.SPARK, 1)[1],
47 1e-6);
48 }
49
50 @Test
51 void ridersStartOnTheFirstPointOfTheirPath() {
52 assertEquals(
53 MarketChartGraphic.YES_CURVE[0],
54 MarketChartGraphic.rideCurve(MarketChartGraphic.YES_CURVE, 0)[0],
55 1e-6);
56 assertEquals(
57 MarketChartGraphic.YES_CURVE[1],
58 MarketChartGraphic.rideCurve(MarketChartGraphic.YES_CURVE, 0)[1],
59 1e-6);
60
61 assertEquals(
62 SparklineGraphic.SPARK[0],
63 SparklineGraphic.ridePolyline(SparklineGraphic.SPARK, 0)[0],
64 1e-6);
65 assertEquals(
66 SparklineGraphic.SPARK[1],
67 SparklineGraphic.ridePolyline(SparklineGraphic.SPARK, 0)[1],
68 1e-6);
69 }
70}
diff --git a/ui-desktop/src/test/java/market/guess/ui/desktop/AppStateTest.java b/ui-desktop/src/test/java/market/guess/ui/desktop/AppStateTest.java
new file mode 100644
index 0000000..dbb1762
--- /dev/null
+++ b/ui-desktop/src/test/java/market/guess/ui/desktop/AppStateTest.java
@@ -0,0 +1,499 @@
1package market.guess.ui.desktop;
2
3import static org.junit.jupiter.api.Assertions.assertEquals;
4import static org.junit.jupiter.api.Assertions.assertNotNull;
5import static org.junit.jupiter.api.Assertions.assertTrue;
6
7import java.math.BigDecimal;
8import java.nio.file.Path;
9import java.util.concurrent.atomic.AtomicBoolean;
10import market.guess.api.CatalogContext;
11import market.guess.model.event.CommissionTiming;
12import market.guess.model.event.CreateEventRequest;
13import market.guess.model.event.MechanismType;
14import market.guess.ui.desktop.components.AppTab;
15import market.guess.ui.desktop.components.ModalDialog;
16import org.junit.jupiter.api.BeforeEach;
17import org.junit.jupiter.api.Test;
18
19class AppStateTest {
20 private AppState state;
21 private CatalogContext catalog;
22 private ServiceEngine engine;
23
24 @BeforeEach
25 void setUp() {
26 engine = new ServiceEngine();
27 var path = Path.of("test-data/ex-2/small.xml");
28 var res = engine.getCatalogContext().loadEvents(path);
29 assertTrue(res.isSuccess(), "Loading small.xml should succeed");
30 catalog = engine.getCatalogContext();
31 state = new AppState(engine);
32 state.setLoadedFile(path.toString());
33 state.refreshData();
34 }
35
36 @Test
37 void reactivePropertiesNotifyListeners() {
38 AtomicBoolean skinChanged = new AtomicBoolean(false);
39 state.skinProperty().addListener((obs, oldVal, newVal) -> skinChanged.set(true));
40 state.setSkin(Skin.CATPPUCCIN);
41 assertTrue(skinChanged.get());
42 assertEquals(Skin.CATPPUCCIN, state.getSkin());
43
44 AtomicBoolean tabChanged = new AtomicBoolean(false);
45 state.activeTabProperty().addListener((obs, oldVal, newVal) -> tabChanged.set(true));
46 state.setActiveTab(AppTab.USERS);
47 assertTrue(tabChanged.get());
48 assertEquals(AppTab.USERS, state.getActiveTab());
49
50 AtomicBoolean dialogChanged = new AtomicBoolean(false);
51 state.dialogProperty().addListener((obs, oldVal, newVal) -> dialogChanged.set(true));
52 state.setDialog(ModalDialog.CREATE_EVENT);
53 assertTrue(dialogChanged.get());
54 assertEquals(ModalDialog.CREATE_EVENT, state.getDialog());
55 }
56
57 @Test
58 void selectedEventUpdatesWhenIdChanges() {
59 assertNotNull(state.getSelectedEvent());
60
61 // Add a new event
62 var req =
63 new CreateEventRequest(
64 "New Test Event",
65 "Test Description",
66 MechanismType.LMSR,
67 5,
68 CommissionTiming.ON_PURCHASE,
69 "Avrum",
70 150,
71 null,
72 java.util.List.of("YES", "NO"),
73 true);
74 var res = catalog.createEvent(req);
75 assertTrue(res.isSuccess(), "Create event should succeed: " + res.getMessage());
76 state.refreshData();
77
78 String newId = res.getData().summary().key();
79 state.setSelectedEventId(newId);
80 assertEquals(newId, state.getSelectedEvent().id);
81 assertEquals("New Test Event", state.getSelectedEvent().name);
82 }
83
84 @Test
85 void actingUserUpdatesWhenNameChanges() {
86 assertNotNull(state.getActingUser());
87 assertEquals("Avrum", state.getActingUserName());
88
89 state.setActingUserName("Menash");
90 assertNotNull(state.getActingUser());
91 assertEquals("Menash", state.getActingUser().name);
92 }
93
94 @Test
95 void createEventWithFullParameters() {
96 var req =
97 new CreateEventRequest(
98 "Summit 2026",
99 "Will the summit take place?",
100 MechanismType.ORDER_BOOK,
101 10,
102 CommissionTiming.ON_CLOSE,
103 "Avrum",
104 null,
105 new BigDecimal("2.50"),
106 java.util.List.of("YES", "NO"),
107 false);
108 var res = catalog.createEvent(req);
109 assertTrue(res.isSuccess(), "Create event should succeed: " + res.getMessage());
110 var detail = res.getData();
111 assertEquals("Summit 2026", detail.summary().name());
112 assertEquals(MechanismType.ORDER_BOOK, detail.summary().mechanism());
113 assertEquals(10, detail.summary().commissionPercent());
114 assertEquals(CommissionTiming.ON_CLOSE, detail.summary().commissionTiming());
115 }
116
117 @Test
118 void testServiceEngineAndXmlLoad() {
119 assertEquals(2, state.getEvents().size());
120 assertEquals(3, state.getUsers().size());
121 assertNotNull(state.getActingUser());
122 }
123
124 @Test
125 void testLmsrTradingAndUpdate() {
126 var openRes = catalog.openEvent("1");
127 assertTrue(openRes.isSuccess(), "Opening event 1 should succeed: " + openRes.getMessage());
128 state.refreshData();
129
130 var evBefore = state.event("1");
131 assertNotNull(evBefore);
132 System.out.println(
133 "Before trade: yesPrice=" + evBefore.yesPrice + ", noPrice=" + evBefore.noPrice);
134
135 var orderRes =
136 engine.getMarketContext().placeOrder("Avrum", "1", "YES", "BUY", evBefore.yesPrice, 20);
137 assertTrue(orderRes.isSuccess(), "Place order should succeed: " + orderRes.getMessage());
138
139 state.refreshData();
140 var evAfter = state.event("1");
141 assertEquals(new BigDecimal("0.55"), evAfter.yesPrice);
142 assertEquals(new BigDecimal("0.45"), evAfter.noPrice);
143 assertEquals(new BigDecimal("0.55"), state.getSelectedEvent().yesPrice);
144
145 System.out.println("=== LMSR EVENT AFTER TRADE ===");
146 System.out.println("evAfter.trades.size() = " + evAfter.trades.size());
147 for (var t : evAfter.trades) {
148 System.out.println(
149 " Trade: n="
150 + t.n()
151 + ", user="
152 + t.user()
153 + ", option="
154 + t.option()
155 + ", yes="
156 + t.yesOption()
157 + ", shares="
158 + t.shares()
159 + ", paid="
160 + t.paid());
161 }
162 System.out.println("evAfter.participants.size() = " + evAfter.participants.size());
163 for (var p : evAfter.participants) {
164 System.out.println(
165 " Participant: user="
166 + p.user()
167 + ", tag="
168 + p.tag()
169 + ", yes="
170 + p.yes()
171 + ", no="
172 + p.no()
173 + ", val="
174 + p.value()
175 + ", fees="
176 + p.fees());
177 }
178 var avrum = state.user("Avrum");
179 System.out.println("Avrum events size: " + avrum.events.size());
180 for (var ue : avrum.events) {
181 System.out.println(
182 " userEvent: name="
183 + ue.eventName()
184 + ", role="
185 + ue.role()
186 + ", yes="
187 + ue.yes()
188 + ", no="
189 + ue.no());
190 }
191 System.out.println("Avrum balanceHistory size: " + avrum.balanceHistory.size());
192 for (var cp : avrum.balanceHistory) {
193 System.out.println(" BalancePoint: x=" + cp.x() + ", y=" + cp.y());
194 }
195 assertTrue(avrum.balanceHistory.size() >= 2);
196 assertTrue(avrum.balanceHistory.get(0).x() > 1_000_000_000);
197
198 // Avrum sells 10 YES shares in LMSR
199 var sellRes = engine.getMarketContext().placeOrder("Avrum", "1", "YES", "SELL", null, 10);
200 assertTrue(
201 sellRes.isSuccess(), "Place LMSR sell order should succeed: " + sellRes.getMessage());
202 state.refreshData();
203
204 var evAfterSell = state.event("1");
205 var avrumParticipant =
206 evAfterSell.participants.stream()
207 .filter(p -> p.user().equals("Avrum"))
208 .findFirst()
209 .orElseThrow();
210 assertEquals(10, avrumParticipant.yes(), "Avrum should hold 10 YES shares after selling 10");
211 assertEquals(10, evAfterSell.yesShares, "Event circulating YES shares should be 10");
212 assertEquals(2, evAfterSell.trades.size(), "Event should have 2 trades recorded");
213
214 var avrumAfterSell = state.user("Avrum");
215 var avrumEv =
216 avrumAfterSell.events.stream()
217 .filter(e -> e.eventId().equals("1"))
218 .findFirst()
219 .orElseThrow();
220 assertEquals(10, avrumEv.yes(), "Avrum user event row should show 10 shares");
221 }
222
223 @Test
224 void testOrderBookTradingAndParticipants() {
225 var openRes = catalog.openEvent("2");
226 assertTrue(openRes.isSuccess());
227 state.refreshData();
228
229 var ev2 = state.event("2");
230 System.out.println("=== ORDER BOOK EVENT BEFORE TRADE ===");
231 System.out.println("ev2.participants.size() = " + ev2.participants.size());
232 for (var p : ev2.participants) {
233 System.out.println(
234 " Participant: user="
235 + p.user()
236 + ", tag="
237 + p.tag()
238 + ", yes="
239 + p.yes()
240 + ", no="
241 + p.no()
242 + ", val="
243 + p.value()
244 + ", fees="
245 + p.fees());
246 }
247
248 // Avrum (MM) quotes his minted YES shares; opening the event doesn't do it for him.
249 var quote =
250 engine
251 .getMarketContext()
252 .placeOrder("Avrum", "2", "YES", "SELL", new BigDecimal("0.50"), 100);
253 assertTrue(quote.isSuccess(), "Avrum quote should succeed: " + quote.getMessage());
254
255 // Menash buys 20 YES @ 0.50 on event 2
256 var res =
257 engine
258 .getMarketContext()
259 .placeOrder("Menash", "2", "YES", "BUY", new BigDecimal("0.50"), 20);
260 assertTrue(res.isSuccess(), "Menash place order should succeed: " + res.getMessage());
261 state.refreshData();
262
263 var ev2After = state.event("2");
264 System.out.println("=== ORDER BOOK EVENT AFTER TRADE ===");
265 System.out.println("ev2After.trades.size() = " + ev2After.trades.size());
266 for (var t : ev2After.trades) {
267 System.out.println(
268 " Trade: n="
269 + t.n()
270 + ", user="
271 + t.user()
272 + ", option="
273 + t.option()
274 + ", yes="
275 + t.yesOption()
276 + ", shares="
277 + t.shares()
278 + ", paid="
279 + t.paid());
280 }
281 System.out.println("ev2After.participants.size() = " + ev2After.participants.size());
282 for (var p : ev2After.participants) {
283 System.out.println(
284 " Participant: user="
285 + p.user()
286 + ", tag="
287 + p.tag()
288 + ", yes="
289 + p.yes()
290 + ", no="
291 + p.no()
292 + ", val="
293 + p.value()
294 + ", fees="
295 + p.fees());
296 }
297 var menash = state.user("Menash");
298 System.out.println("Menash events size: " + menash.events.size());
299 for (var ue : menash.events) {
300 System.out.println(
301 " Menash userEvent: name="
302 + ue.eventName()
303 + ", role="
304 + ue.role()
305 + ", yes="
306 + ue.yes()
307 + ", no="
308 + ue.no());
309 }
310
311 // Menash SELLS 10 YES @ 0.40 (better price than Avrum's 0.50)
312 var sellRes =
313 engine
314 .getMarketContext()
315 .placeOrder("Menash", "2", "YES", "SELL", new BigDecimal("0.40"), 10);
316 assertTrue(sellRes.isSuccess(), "Menash sell order should succeed: " + sellRes.getMessage());
317 state.refreshData();
318
319 // Tikva BUYS 10 YES @ 0.50 (crosses Menash's 0.40 ask!)
320 var tikvaBuy =
321 engine
322 .getMarketContext()
323 .placeOrder("Tikva", "2", "YES", "BUY", new BigDecimal("0.50"), 10);
324 assertTrue(tikvaBuy.isSuccess());
325 state.refreshData();
326
327 var ev2AfterSell = state.event("2");
328 System.out.println("=== ORDER BOOK EVENT AFTER SELL TRADE ===");
329 System.out.println("ev2AfterSell.trades.size() = " + ev2AfterSell.trades.size());
330 for (var t : ev2AfterSell.trades) {
331 System.out.println(
332 " Trade: n="
333 + t.n()
334 + ", user="
335 + t.user()
336 + ", option="
337 + t.option()
338 + ", yes="
339 + t.yesOption()
340 + ", shares="
341 + t.shares()
342 + ", paid="
343 + t.paid());
344 }
345 var domainEv2 = engine.getCatalogContext().getEvent("2");
346 System.out.println("Domain event 2 trades count: " + domainEv2.getData());
347 long mShares0 =
348 engine.getCatalogContext().getEvent("2").getData().participants().stream()
349 .filter(p -> p.user().equals("Menash"))
350 .findFirst()
351 .get()
352 .yes();
353 System.out.println("Menash YES shares in ParticipantDTO: " + mShares0);
354 System.out.println("ev2AfterSell.participants.size() = " + ev2AfterSell.participants.size());
355 for (var p : ev2AfterSell.participants) {
356 System.out.println(
357 " Participant: user="
358 + p.user()
359 + ", tag="
360 + p.tag()
361 + ", yes="
362 + p.yes()
363 + ", no="
364 + p.no()
365 + ", val="
366 + p.value()
367 + ", fees="
368 + p.fees());
369 }
370 menash = state.user("Menash");
371 for (var ue : menash.events) {
372 System.out.println(
373 " Menash userEvent after sell: name="
374 + ue.eventName()
375 + ", role="
376 + ue.role()
377 + ", yes="
378 + ue.yes()
379 + ", no="
380 + ue.no());
381 }
382
383 var menashP =
384 ev2AfterSell.participants.stream()
385 .filter(p -> p.user().equals("Menash"))
386 .findFirst()
387 .orElseThrow();
388 assertEquals(10, menashP.yes(), "Menash should have 10 YES shares after selling 10 of 20");
389 var tikvaP =
390 ev2AfterSell.participants.stream()
391 .filter(p -> p.user().equals("Tikva"))
392 .findFirst()
393 .orElseThrow();
394 assertEquals(10, tikvaP.yes(), "Tikva should have 10 YES shares after buying 10");
395 var avrumP =
396 ev2AfterSell.participants.stream()
397 .filter(p -> p.user().equals("Avrum"))
398 .findFirst()
399 .orElseThrow();
400 assertEquals(80, avrumP.yes(), "Avrum (MM) should have 80 YES shares");
401 assertEquals(2, ev2AfterSell.trades.size(), "Order book event should have 2 trades");
402 }
403
404 @Test
405 void testEventsTabControllerProgressBar() throws Exception {
406 try {
407 javafx.application.Platform.startup(() -> {});
408 } catch (IllegalStateException ignored) {
409 }
410
411 var loader = new javafx.fxml.FXMLLoader(getClass().getResource("controllers/events_tab.fxml"));
412 javafx.scene.Parent root = loader.load();
413 market.guess.ui.desktop.controllers.EventsTabController ctrl = loader.getController();
414
415 ctrl.init(state);
416 ctrl.refresh();
417
418 var field =
419 market.guess.ui.desktop.controllers.EventsTabController.class.getDeclaredField(
420 "lmsrYesProgressBar");
421 field.setAccessible(true);
422 javafx.scene.control.ProgressBar pb = (javafx.scene.control.ProgressBar) field.get(ctrl);
423
424 assertEquals(0.5, pb.getProgress(), 0.001);
425
426 // Switch to event 2 (Order Book)
427 state.setSelectedEventId("2");
428 // Switch back to event 1 (LMSR)
429 state.setSelectedEventId("1");
430 assertEquals(0.5, pb.getProgress(), 0.001);
431
432 // Open event 1
433 catalog.openEvent("1");
434 state.refreshData();
435 assertEquals(0.5, pb.getProgress(), 0.001);
436
437 // Trade 20 YES on event 1
438 var res =
439 engine
440 .getMarketContext()
441 .placeOrder("Avrum", "1", "YES", "BUY", state.event("1").yesPrice, 20);
442 assertTrue(res.isSuccess());
443 state.refreshData();
444 assertEquals(0.55, pb.getProgress(), 0.001);
445
446 root.getStyleClass().add("gm-root");
447 javafx.scene.Scene scene = new javafx.scene.Scene(root);
448 scene.getStylesheets().add(getClass().getResource("theme.css").toExternalForm());
449 root.applyCss();
450 root.layout();
451
452 javafx.scene.Node track = pb.lookup(".track");
453 javafx.scene.Node bar = pb.lookup(".bar");
454 assertNotNull(track);
455 assertNotNull(bar);
456 if (bar instanceof javafx.scene.layout.Region rBar) {
457 assertEquals(0.0, rBar.getPadding().getTop(), 0.001);
458 assertEquals(0.0, rBar.getPadding().getBottom(), 0.001);
459 assertNotNull(rBar.getBackground());
460 assertEquals(1, rBar.getBackground().getFills().size());
461 assertEquals(0.0, rBar.getBackground().getFills().get(0).getInsets().getTop(), 0.001);
462 }
463 if (track instanceof javafx.scene.layout.Region rTrack) {
464 assertEquals(0.0, rTrack.getPadding().getTop(), 0.001);
465 assertEquals(0.0, rTrack.getPadding().getBottom(), 0.001);
466 assertNotNull(rTrack.getBackground());
467 }
468 }
469
470 @Test
471 void testEventDataAndUserDataEquals() {
472 var ev1 = state.event("1");
473 assertNotNull(ev1);
474 var ev1Copy =
475 new market.guess.ui.desktop.model.EventData(
476 ev1.id,
477 ev1.num,
478 ev1.name,
479 ev1.desc,
480 ev1.type,
481 ev1.status,
482 ev1.mm,
483 ev1.feePercent,
484 ev1.feeMode,
485 ev1.contract.doubleValue(),
486 ev1.liquidityB,
487 ev1.baseValueD);
488 assertEquals(ev1, ev1Copy);
489 assertEquals(ev1.hashCode(), ev1Copy.hashCode());
490
491 var u1 = state.user("Avrum");
492 assertNotNull(u1);
493 var u1Copy =
494 new market.guess.ui.desktop.model.UserData(
495 "Avrum", u1.role, u1.balance.doubleValue(), u1.blocked, u1.isMm);
496 assertEquals(u1, u1Copy);
497 assertEquals(u1.hashCode(), u1Copy.hashCode());
498 }
499}
diff --git a/ui-desktop/src/test/java/market/guess/ui/desktop/ChartsTest.java b/ui-desktop/src/test/java/market/guess/ui/desktop/ChartsTest.java
new file mode 100644
index 0000000..c5cb280
--- /dev/null
+++ b/ui-desktop/src/test/java/market/guess/ui/desktop/ChartsTest.java
@@ -0,0 +1,32 @@
1package market.guess.ui.desktop;
2
3import static org.junit.jupiter.api.Assertions.assertEquals;
4import static org.junit.jupiter.api.Assertions.assertTrue;
5
6import market.guess.ui.desktop.util.Charts;
7import org.junit.jupiter.api.Test;
8
9class ChartsTest {
10
11 @Test
12 void testToEpochSecondFromIndex() {
13 double t0 = Charts.toEpochSecond(0);
14 double t1 = Charts.toEpochSecond(1);
15 double t2 = Charts.toEpochSecond(2);
16
17 assertEquals(Charts.BASE_TIME, t0, 1e-6);
18 assertEquals(Charts.BASE_TIME + 1800, t1, 1e-6);
19 assertEquals(Charts.BASE_TIME + 3600, t2, 1e-6);
20 }
21
22 @Test
23 void testToEpochSecondIdempotentForTimestamps() {
24 long epoch = 1_700_000_000L;
25 assertEquals(epoch, Charts.toEpochSecond(epoch), 1e-6);
26 }
27
28 @Test
29 void testBaseTimeIsReasonable() {
30 assertTrue(Charts.BASE_TIME > 1_700_000_000L);
31 }
32}
diff --git a/ui-desktop/src/test/java/market/guess/ui/desktop/FormatTest.java b/ui-desktop/src/test/java/market/guess/ui/desktop/FormatTest.java
new file mode 100644
index 0000000..956a322
--- /dev/null
+++ b/ui-desktop/src/test/java/market/guess/ui/desktop/FormatTest.java
@@ -0,0 +1,29 @@
1package market.guess.ui.desktop;
2
3import static org.junit.jupiter.api.Assertions.assertEquals;
4
5import java.math.BigDecimal;
6import market.guess.ui.desktop.util.Format;
7import org.junit.jupiter.api.Test;
8
9class FormatTest {
10
11 @Test
12 void testMoneyFormatting() {
13 assertEquals("$0.00", Format.money((BigDecimal) null));
14 assertEquals("$0.00", Format.money(BigDecimal.ZERO));
15 assertEquals("$0.50", Format.money(new BigDecimal("0.50")));
16 assertEquals("$123.45", Format.money(new BigDecimal("123.45")));
17 assertEquals("-$5.00", Format.money(new BigDecimal("-5.00")));
18 assertEquals("$100.00", Format.money(100.0));
19 assertEquals("-$2.50", Format.money(-2.50));
20 }
21
22 @Test
23 void testProbFormatting() {
24 assertEquals("0.50", Format.prob(0.50));
25 assertEquals("0.75", Format.prob(0.751));
26 assertEquals("0.00", Format.prob(0.00));
27 assertEquals("1.00", Format.prob(1.00));
28 }
29}