From 755d2265054e5140e82d322681566c3034583c95 Mon Sep 17 00:00:00 2001 From: Kostya Date: Tue, 18 Aug 2026 18:19:50 +0300 Subject: Break down purchase receipts by cost/commission, harden XML validation and state I/O Adds a full pre-load validation pass for GM XML seeds (ids, names, options, commission, LMSR liquidity), replaces IO.print with System.out, and surfaces detailed error messages across load/save commands instead of generic failures. --- .../guess/service/LocalGuessMarketContext.java | 20 +- .../catalog/infrastructure/MarketContext.java | 18 +- .../infrastructure/mapper/v1/EventMapperV1.java | 17 +- .../infrastructure/provider/v1/XMLLoaderV1.java | 34 +- .../infrastructure/provider/v1/XMLValidatorV1.java | 80 ++++- .../java/market/guess/service/Ex1FullTest.java | 343 +++++++++++++++++++++ 6 files changed, 485 insertions(+), 27 deletions(-) create mode 100644 service/src/test/java/market/guess/service/Ex1FullTest.java (limited to 'service') diff --git a/service/src/main/java/market/guess/service/LocalGuessMarketContext.java b/service/src/main/java/market/guess/service/LocalGuessMarketContext.java index 909a808..f04324b 100644 --- a/service/src/main/java/market/guess/service/LocalGuessMarketContext.java +++ b/service/src/main/java/market/guess/service/LocalGuessMarketContext.java @@ -10,6 +10,7 @@ import market.guess.model.event.EventDetailDTO; import market.guess.service.catalog.infrastructure.MarketContext; import market.guess.service.domain.Order; import market.guess.service.fulfillment.FulfillmentContext; +import market.guess.service.helpers.BigDecimalOptions; import market.guess.service.helpers.InstantOptions; import market.guess.service.matching.MatchingEngine; import market.guess.service.risk.RiskEngine; @@ -55,6 +56,20 @@ public final class LocalGuessMarketContext implements GuessMarketContext { var rawTrades = matching.match(order); var trades = fulfillment.fulfill(user, order, rawTrades); + + var totalSharesCost = + trades.stream() + .map(t -> t.sharesCost()) + .reduce(BigDecimalOptions.ZERO_MONEY, BigDecimal::add); + var totalCommission = + trades.stream() + .map(t -> t.commission()) + .reduce(BigDecimalOptions.ZERO_MONEY, BigDecimal::add); + var totalPaid = + trades.stream() + .map(t -> t.totalPaid()) + .reduce(BigDecimalOptions.ZERO_MONEY, BigDecimal::add); + return Result.ok( new PurchaseReceiptDTO( trades.stream() @@ -65,8 +80,11 @@ public final class LocalGuessMarketContext implements GuessMarketContext { t.buyerUserName(), t.marketName(), String.valueOf(t.quantity()), - t.totalPaid().toPlainString())) + t.sharesCost().toPlainString())) .toList(), + totalSharesCost.toPlainString(), + totalCommission.toPlainString(), + totalPaid.toPlainString(), event.toMarketState())); } diff --git a/service/src/main/java/market/guess/service/catalog/infrastructure/MarketContext.java b/service/src/main/java/market/guess/service/catalog/infrastructure/MarketContext.java index cb2b467..5fc2e24 100644 --- a/service/src/main/java/market/guess/service/catalog/infrastructure/MarketContext.java +++ b/service/src/main/java/market/guess/service/catalog/infrastructure/MarketContext.java @@ -67,15 +67,22 @@ public final class MarketContext { GSON.toJson(new Wrapper(events.getAll(), users.getAll())), StandardCharsets.UTF_8); } catch (Exception e) { - throw new GuessMarketException("Failed to sove the file."); + throw new GuessMarketException("Failed to save state to " + target + ": " + e.getMessage()); } } public void load(Path path) throws GuessMarketException { + var target = withJsonExtension(path); + if (!Files.isRegularFile(target)) { + throw new GuessMarketException("State file not found: " + target); + } try { var wrapper = GSON.fromJson( - Files.readString(withJsonExtension(path), StandardCharsets.UTF_8), Wrapper.class); + Files.readString(target, StandardCharsets.UTF_8), Wrapper.class); + if (wrapper == null || wrapper.events() == null) { + throw new GuessMarketException("State file contains invalid or empty data."); + } events.clear(); users.clear(); @@ -88,14 +95,15 @@ public final class MarketContext { } } catch (JsonSyntaxException e) { - throw new GuessMarketException("The JSON file is malformed."); + throw new GuessMarketException("The state file is malformed JSON: " + e.getMessage()); } catch (IOException e) { - throw new GuessMarketException("Unable the file."); + throw new GuessMarketException("Unable to read state file: " + e.getMessage()); } } private static Path withJsonExtension(Path path) { + if (path == null) return null; var name = path.getFileName().toString(); - return name.endsWith(".json") ? path : path.resolveSibling(name + ".json"); + return name.toLowerCase().endsWith(".json") ? path : path.resolveSibling(name + ".json"); } } diff --git a/service/src/main/java/market/guess/service/catalog/infrastructure/mapper/v1/EventMapperV1.java b/service/src/main/java/market/guess/service/catalog/infrastructure/mapper/v1/EventMapperV1.java index f45eb46..b9c43bb 100644 --- a/service/src/main/java/market/guess/service/catalog/infrastructure/mapper/v1/EventMapperV1.java +++ b/service/src/main/java/market/guess/service/catalog/infrastructure/mapper/v1/EventMapperV1.java @@ -19,8 +19,8 @@ public final class EventMapperV1 implements Mapper { return new Event( getEventKey(source), source.getId(), - joinName(source.getName()), - source.getDescription(), + joinName(source.getName()).trim(), + source.getDescription() != null ? source.getDescription().trim() : "", source.getComision().getValue(), getTiming(source.getComision().getType()), new LmsrTradingMechanism(source.getGMMethod().getGMLMSR().getB(), options.size()), @@ -38,17 +38,20 @@ public final class EventMapperV1 implements Mapper { } private static CommissionTiming getTiming(String type) throws IllegalArgumentException { - return switch (type) { + if (type == null) throw new IllegalArgumentException("Commission type cannot be null"); + return switch (type.toLowerCase().trim()) { case "on-close" -> CommissionTiming.ON_CLOSE; case "on-purchase" -> CommissionTiming.ON_PURCHASE; - default -> throw new IllegalArgumentException("Unknown comision type: " + type); + default -> throw new IllegalArgumentException("Unknown commission type: " + type); }; } private static List getOptions(GMEvent x) { - var ret = new ArrayList(x.getGMOptions().getGMOption().size()); - for (var i = 0; i < x.getGMOptions().getGMOption().size(); i++) { - ret.add(new Market(getEventKey(x) + ":" + i, x.getGMOptions().getGMOption().get(i))); + var raw = x.getGMOptions().getGMOption(); + var ret = new ArrayList(raw.size()); + for (var i = 0; i < raw.size(); i++) { + var opt = raw.get(i); + ret.add(new Market(getEventKey(x) + ":" + i, opt != null ? opt.trim() : "")); } return ret; } diff --git a/service/src/main/java/market/guess/service/catalog/infrastructure/provider/v1/XMLLoaderV1.java b/service/src/main/java/market/guess/service/catalog/infrastructure/provider/v1/XMLLoaderV1.java index bcae787..44e1c7f 100644 --- a/service/src/main/java/market/guess/service/catalog/infrastructure/provider/v1/XMLLoaderV1.java +++ b/service/src/main/java/market/guess/service/catalog/infrastructure/provider/v1/XMLLoaderV1.java @@ -2,8 +2,10 @@ package market.guess.service.catalog.infrastructure.provider.v1; import jakarta.xml.bind.JAXBContext; import jakarta.xml.bind.JAXBException; +import java.math.BigDecimal; import java.nio.file.Files; import java.nio.file.Path; +import java.util.ArrayList; import market.guess.api.CatalogContext; import market.guess.api.LoadResult; import market.guess.api.Result; @@ -12,6 +14,7 @@ import market.guess.service.catalog.infrastructure.provider.Loader; import market.guess.service.catalog.infrastructure.repository.EventRepository; import market.guess.service.catalog.infrastructure.repository.UserRepository; import market.guess.service.catalog.model.v1.GuessMarket; +import market.guess.service.domain.Event; import market.guess.service.domain.User; public final class XMLLoaderV1 implements Loader { @@ -35,19 +38,23 @@ public final class XMLLoaderV1 implements Loader { try { context = JAXBContext.newInstance(GuessMarket.class); } catch (JAXBException e) { - throw new IllegalStateException(); + throw new IllegalStateException("Failed to initialize JAXB context: " + e.getMessage(), e); } } @Override public Result load(Path path) { + if (path == null) { + return Result.error("INVALID_PATH", "File path cannot be null."); + } + if (!Files.isRegularFile(path)) { - return Result.error("FILE_NOT_FOUND", "No such file."); + return Result.error("FILE_NOT_FOUND", "No such file: " + path); } if (path.getFileName() == null || !path.getFileName().toString().toLowerCase().endsWith(".xml")) { - return Result.error("NOT_XML", "The file must be an XML."); + return Result.error("NOT_XML", "The file must have a .xml extension."); } try (var stream = Files.newInputStream(path)) { @@ -59,21 +66,34 @@ public final class XMLLoaderV1 implements Loader { return Result.error(validation.getMessage(), validation.getDetails()); } + var domainEvents = new ArrayList(); for (var event : seed.getGMEvents().getGMEvent()) { - eventRepository.add(mapper.toDomain(event)); + domainEvents.add(mapper.toDomain(event)); + } + + eventRepository.clear(); + userRepository.clear(); + + for (var domainEvent : domainEvents) { + eventRepository.add(domainEvent); } - userRepository.add(new User("Tester", 500)); + userRepository.add(new User("Tester", new BigDecimal("500.00"))); return Result.ok(new LoadResult(path.toString(), eventRepository.getAll().size())); + } catch (JAXBException e) { + var cause = + e.getLinkedException() != null ? e.getLinkedException().getMessage() : e.getMessage(); + return Result.error( + "XML_PARSE_ERROR", + "The XML file is malformed or invalid: " + (cause != null ? cause : e.getMessage())); } catch (Exception e) { - return Result.error("UNREADABLE", "The file could not be read."); + return Result.error("UNREADABLE", "The file could not be read: " + e.getMessage()); } } @Override public Result seed(CatalogContext context) { - // TODO Auto-generated method stub throw new UnsupportedOperationException("Unimplemented method 'seed'"); } } diff --git a/service/src/main/java/market/guess/service/catalog/infrastructure/provider/v1/XMLValidatorV1.java b/service/src/main/java/market/guess/service/catalog/infrastructure/provider/v1/XMLValidatorV1.java index 567558a..5a4e5ea 100644 --- a/service/src/main/java/market/guess/service/catalog/infrastructure/provider/v1/XMLValidatorV1.java +++ b/service/src/main/java/market/guess/service/catalog/infrastructure/provider/v1/XMLValidatorV1.java @@ -9,27 +9,93 @@ public final class XMLValidatorV1 { private static final int MAX_COMMISSION = 90; public Result validate(GuessMarket seed) { + if (seed == null || seed.getGMEvents() == null || seed.getGMEvents().getGMEvent() == null) { + return Result.error("INVALID_XML", "The XML file contains no GM-events element."); + } - var seenIds = new HashSet(); - var duplicated = new HashSet(); + var events = seed.getGMEvents().getGMEvent(); + if (events.isEmpty()) { + return Result.error("EMPTY_EVENTS", "The XML file does not contain any events."); + } - for (var e : seed.getGMEvents().getGMEvent()) { + var seenIds = new HashSet(); - if (!seenIds.add(e.getId()) && duplicated.add(e.getId())) { + for (var e : events) { + // 1. Event ID must be positive integer (> 0) + if (e.getId() <= 0) { + return Result.error( + "INVALID_ID", + "Event ID must be a positive integer (> 0), found: " + e.getId()); + } + if (!seenIds.add(e.getId())) { return Result.error( "DUPLICATE_ID", - "Event id " + e.getId() + " appears more than once, every event id must be unique."); + "Event ID " + e.getId() + " appears more than once. Every event ID must be unique."); } + // 2. Event name must not be empty + if (e.getName() == null || e.getName().isEmpty() || String.join(" ", e.getName()).isBlank()) { + return Result.error("MISSING_NAME", "Event ID " + e.getId() + " has an empty name."); + } + + // 3. Event description must not be empty + if (e.getDescription() == null || e.getDescription().isBlank()) { + return Result.error("MISSING_DESCRIPTION", "Event ID " + e.getId() + " has an empty description."); + } + + // 4. Commission validation + if (e.getComision() == null) { + return Result.error("MISSING_COMMISSION", "Event ID " + e.getId() + " is missing commission."); + } var pct = e.getComision().getValue(); if (pct < MIN_COMMISSION || pct > MAX_COMMISSION) { return Result.error( "COMMISSION_OUT_OF_RANGE", - "Commission is %d%%, it must be between %d%% and %d%%." - .formatted(pct, MIN_COMMISSION, MAX_COMMISSION)); + "Event ID " + e.getId() + " commission is " + pct + "%. It must be between " + MIN_COMMISSION + "% and " + MAX_COMMISSION + "%."); + } + + var type = e.getComision().getType(); + if (type == null || (!type.equalsIgnoreCase("on-close") && !type.equalsIgnoreCase("on-purchase"))) { + return Result.error( + "INVALID_COMMISSION_TYPE", + "Event ID " + e.getId() + " has invalid commission type '" + type + "'. Must be 'on-close' or 'on-purchase'."); + } + + // 5. GM-options validation (exactly 2 options for Exercise 1) + if (e.getGMOptions() == null || e.getGMOptions().getGMOption() == null) { + return Result.error("MISSING_OPTIONS", "Event ID " + e.getId() + " is missing GM-options."); + } + var options = e.getGMOptions().getGMOption(); + if (options.size() != 2) { + return Result.error( + "INVALID_OPTIONS_COUNT", + "Event ID " + e.getId() + " must have exactly 2 options for Exercise 1, but found " + options.size() + "."); + } + var opt0 = options.get(0) == null ? "" : options.get(0).trim(); + var opt1 = options.get(1) == null ? "" : options.get(1).trim(); + if (opt0.isEmpty() || opt1.isEmpty()) { + return Result.error("EMPTY_OPTION", "Event ID " + e.getId() + " has an empty option name."); + } + if (opt0.equalsIgnoreCase(opt1)) { + return Result.error("DUPLICATE_OPTION", "Event ID " + e.getId() + " has duplicate option names: '" + opt0 + "'."); + } + + // 6. GM-method and LMSR validation + if (e.getGMMethod() == null) { + return Result.error("MISSING_METHOD", "Event ID " + e.getId() + " is missing GM-method."); + } + if (e.getGMMethod().getGMLMSR() == null) { + return Result.error("INVALID_METHOD", "Event ID " + e.getId() + " must use LMSR trading method in Exercise 1."); + } + var b = e.getGMMethod().getGMLMSR().getB(); + if (b <= 0) { + return Result.error( + "INVALID_LIQUIDITY", + "Event ID " + e.getId() + " has invalid liquidity b=" + b + ". b must be a positive integer (> 0)."); } } return Result.ok(); } } + diff --git a/service/src/test/java/market/guess/service/Ex1FullTest.java b/service/src/test/java/market/guess/service/Ex1FullTest.java new file mode 100644 index 0000000..6718142 --- /dev/null +++ b/service/src/test/java/market/guess/service/Ex1FullTest.java @@ -0,0 +1,343 @@ +package market.guess.service; + +import static org.junit.jupiter.api.Assertions.*; + +import java.math.BigDecimal; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Instant; +import java.util.List; +import market.guess.api.CommissionTiming; +import market.guess.model.event.EventStatus; +import market.guess.service.catalog.LocalCatalogContext; +import market.guess.service.catalog.infrastructure.MarketContext; +import market.guess.service.catalog.infrastructure.mapper.v1.EventMapperV1; +import market.guess.service.catalog.infrastructure.provider.v1.XMLLoaderV1; +import market.guess.service.catalog.infrastructure.provider.v1.XMLValidatorV1; +import market.guess.service.catalog.infrastructure.repository.InMemoryEventRepository; +import market.guess.service.catalog.infrastructure.repository.InMemoryUserRepository; +import market.guess.service.domain.Event; +import market.guess.service.domain.Market; +import market.guess.service.domain.User; +import market.guess.service.fulfillment.LocalFulfillmentContext; +import market.guess.service.helpers.BigDecimalOptions; +import market.guess.service.ledger.LedgerContext; +import market.guess.service.matching.LocalMatchingEngine; +import market.guess.service.mechanism.LmsrTradingMechanism; +import market.guess.service.risk.LocalRiskEngine; +import market.guess.service.settlement.LocalSettlementContext; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +public class Ex1FullTest { + + private InMemoryEventRepository eventRepo; + private InMemoryUserRepository userRepo; + private MarketContext marketContext; + private XMLLoaderV1 loader; + private XMLValidatorV1 validator; + private LocalCatalogContext catalog; + private LocalGuessMarketContext guessMarket; + private LedgerContext ledgerContext; + private LocalSettlementContext settlementContext; + + @BeforeEach + public void setUp() { + eventRepo = new InMemoryEventRepository(); + userRepo = new InMemoryUserRepository(); + marketContext = new MarketContext(eventRepo, userRepo); + validator = new XMLValidatorV1(); + loader = new XMLLoaderV1(eventRepo, userRepo, new EventMapperV1(), validator); + catalog = new LocalCatalogContext(loader, marketContext, eventRepo); + ledgerContext = new LedgerContext(userRepo, java.time.Clock.systemUTC()); + settlementContext = new LocalSettlementContext(ledgerContext); + guessMarket = + new LocalGuessMarketContext( + marketContext, + new LocalRiskEngine(), + new LocalMatchingEngine(java.time.Clock.systemUTC()), + new LocalFulfillmentContext(ledgerContext), + settlementContext); + } + + @Test + public void testLmsrMathCalculations() { + var lmsr = new LmsrTradingMechanism(100, 2); + + // Initial prices should be 0.50 and 0.50 + var initialPrices = lmsr.prices(); + assertEquals(new BigDecimal("0.50"), initialPrices[0]); + assertEquals(new BigDecimal("0.50"), initialPrices[1]); + + // Initial max loss / subsidy = 100 * ln(2) = 69.31 + assertEquals(new BigDecimal("69.31"), lmsr.maxLoss()); + + // Buy 100 shares of option 0 (YES) + var dummyEvent = + new Event( + "1", + 1, + "Test Event", + "Desc", + 0, + CommissionTiming.ON_PURCHASE, + lmsr, + EventStatus.ACTIVE, + List.of(new Market("1:0", "YES"), new Market("1:1", "NO")), + "Tester"); + + var trades = lmsr.buy(dummyEvent, dummyEvent.getMarkets().get(0), Instant.now(), 100); + assertEquals(1, trades.size()); + assertEquals(100, trades.get(0).quantity()); + assertEquals(new BigDecimal("62.01"), trades.get(0).sharesCost()); + + // Price after 100 shares of YES should be ~0.73 and ~0.27 + var pricesAfter = lmsr.prices(); + assertEquals(new BigDecimal("0.73"), pricesAfter[0]); + assertEquals(new BigDecimal("0.27"), pricesAfter[1]); + } + + @Test + public void testXmlLoadingAndAtomicity() { + var xmlPath = Path.of("test-data/ex-1/multiple.xml"); + assertTrue(Files.exists(xmlPath), "test-data/ex-1/multiple.xml must exist"); + + var result = catalog.loadEvents(xmlPath); + assertTrue(result.isSuccess(), "Loading multiple.xml should succeed"); + assertEquals(3, result.getData().eventsLoaded()); + assertEquals(3, eventRepo.getAll().size()); + + // Re-loading multiple.xml should replace, NOT accumulate (size must remain 3) + var reloadResult = catalog.loadEvents(xmlPath); + assertTrue(reloadResult.isSuccess()); + assertEquals(3, eventRepo.getAll().size()); + + // Loading non-existent file should fail and preserve 3 events + var failResult = catalog.loadEvents(Path.of("non_existent_file.xml")); + assertFalse(failResult.isSuccess()); + assertEquals(3, eventRepo.getAll().size()); + + // Loading non-xml file should fail and preserve 3 events + var notXmlResult = catalog.loadEvents(Path.of("README.md")); + assertFalse(notXmlResult.isSuccess()); + assertEquals(3, eventRepo.getAll().size()); + } + + @Test + public void testPlaceOrderWithOnPurchaseCommission() { + var xmlPath = Path.of("test-data/ex-1/multiple.xml"); + catalog.loadEvents(xmlPath); + + // Event 1 has 5% commission on-purchase + var event1 = eventRepo.get("1").orElseThrow(); + assertEquals(5, event1.getCommissionPercent()); + assertEquals(CommissionTiming.ON_PURCHASE, event1.getCommissionTiming()); + + var option0Key = event1.getMarkets().get(0).getKey(); + var buyResult = guessMarket.placeOrder("Tester", "1", option0Key, new BigDecimal("0.50"), 100); + + assertTrue(buyResult.isSuccess()); + var receipt = buyResult.getData(); + assertNotNull(receipt); + assertEquals("62.01", receipt.sharesCost()); + assertEquals("3.10", receipt.commissionPaid()); // 5% of 62.01 = 3.1005 -> 3.10 + assertEquals("65.11", receipt.totalPaid()); // 62.01 + 3.10 = 65.11 + + // History should contain 1 trade + assertEquals(1, event1.getHistory().size()); + assertEquals("62.01", event1.getHistory().get(0).pricePaid()); + } + + @Test + public void testSettleEventWithOnCloseCommission() { + var xmlPath = Path.of("test-data/ex-1/multiple.xml"); + catalog.loadEvents(xmlPath); + + // Event 2 has 15% commission on-close, b=50 + var event2 = eventRepo.get("2").orElseThrow(); + assertEquals(15, event2.getCommissionPercent()); + assertEquals(CommissionTiming.ON_CLOSE, event2.getCommissionTiming()); + + var option0Key = event2.getMarkets().get(0).getKey(); + // Buy 50 shares of Argentina + var buyResult = guessMarket.placeOrder("Tester", "2", option0Key, new BigDecimal("0.50"), 50); + assertTrue(buyResult.isSuccess()); + var receipt = buyResult.getData(); + // On purchase commission is 0.00 + assertEquals("0.00", receipt.commissionPaid()); + + // Now settle event choosing option 0 as winner + var settleResult = guessMarket.settleEvent("Tester", "2", option0Key); + assertTrue(settleResult.isSuccess()); + + var detail = settleResult.getData(); + assertEquals(EventStatus.SETTLED, detail.summary().status()); + assertEquals(event2.getMarkets().get(0).getName(), detail.settledMarket()); + + // 50 winning shares @ $1 gross = $50.00. 15% commission = $7.50. Net payout = $42.50. + assertEquals(new BigDecimal("7.50"), event2.getCommission()); + } + + @Test + public void testStateSaveAndRestoreBonusRoundTrip() throws Exception { + var xmlPath = Path.of("test-data/ex-1/multiple.xml"); + catalog.loadEvents(xmlPath); + + // Trade on event 1 + var optKey = eventRepo.get("1").orElseThrow().getMarkets().get(0).getKey(); + guessMarket.placeOrder("Tester", "1", optKey, new BigDecimal("0.50"), 100); + + // Save to temp file + var tempDir = Files.createTempDirectory("gm_test_save"); + var savePath = tempDir.resolve("snapshot"); + + var saveRes = catalog.saveState(savePath); + assertTrue(saveRes.isSuccess(), "Saving state should succeed"); + assertTrue(Files.exists(tempDir.resolve("snapshot.json")), "snapshot.json must exist"); + + // Clear repositories + eventRepo.clear(); + userRepo.clear(); + assertEquals(0, eventRepo.getAll().size()); + + // Restore from path (without extension) + var restoreRes = catalog.restoreState(savePath); + assertTrue(restoreRes.isSuccess(), "Restoring state should succeed"); + assertEquals(3, restoreRes.getData().eventsLoaded()); + assertEquals(3, eventRepo.getAll().size()); + + var restoredEvent1 = eventRepo.get("1").orElseThrow(); + assertEquals(1, restoredEvent1.getTrades().size()); + assertEquals(100, restoredEvent1.getTrades().get(0).quantity()); + assertEquals(new BigDecimal("62.01"), restoredEvent1.getTrades().get(0).sharesCost()); + } + + @Test + public void testXmlValidationRules() throws Exception { + var tempDir = Files.createTempDirectory("gm_xml_val_test"); + + // 1. Commission > 90 + var xmlInvalidComm = + """ + + + + + 1 + Desc + 95 + + Yes + No + + 100 + + + + """; + var p1 = tempDir.resolve("high_comm.xml"); + Files.writeString(p1, xmlInvalidComm); + var res1 = catalog.loadEvents(p1); + assertFalse(res1.isSuccess()); + assertTrue(res1.getDetails().contains("between 0% and 90%")); + + // 2. Duplicate Event IDs + var xmlDupId = + """ + + + + + 1 + Desc 1 + 10 + YesNo + 100 + + + 1 + Desc 2 + 10 + YesNo + 100 + + + + """; + var p2 = tempDir.resolve("dup_id.xml"); + Files.writeString(p2, xmlDupId); + var res2 = catalog.loadEvents(p2); + assertFalse(res2.isSuccess()); + assertTrue(res2.getDetails().contains("Every event ID must be unique")); + + // 3. Three options (must be exactly 2 for Ex1) + var xml3Opts = + """ + + + + + 1 + Desc + 10 + + Opt 1 + Opt 2 + Opt 3 + + 100 + + + + """; + var p3 = tempDir.resolve("three_opts.xml"); + Files.writeString(p3, xml3Opts); + var res3 = catalog.loadEvents(p3); + assertFalse(res3.isSuccess()); + assertTrue(res3.getDetails().contains("must have exactly 2 options")); + + // 4. Invalid liquidity b <= 0 + var xmlBadB = + """ + + + + + 1 + Desc + 10 + YesNo + 0 + + + + """; + var p4 = tempDir.resolve("bad_b.xml"); + Files.writeString(p4, xmlBadB); + var res4 = catalog.loadEvents(p4); + assertFalse(res4.isSuccess()); + assertTrue(res4.getDetails().contains("positive integer")); + + // 5. Invalid commission type + var xmlBadCommType = + """ + + + + + 1 + Desc + 10 + YesNo + 100 + + + + """; + var p5 = tempDir.resolve("bad_comm_type.xml"); + Files.writeString(p5, xmlBadCommType); + var res5 = catalog.loadEvents(p5); + assertFalse(res5.isSuccess()); + assertTrue(res5.getDetails().contains("Must be 'on-close' or 'on-purchase'")); + } +} -- cgit v1.2.3