diff --git a/crypto_converter/crypto_amount_parser.py b/crypto_converter/crypto_amount_parser.py index 2e7888a..8c23c7f 100644 --- a/crypto_converter/crypto_amount_parser.py +++ b/crypto_converter/crypto_amount_parser.py @@ -26,7 +26,8 @@ rf"(?{NUMBER_LITERAL_REGEX})" rf"(?P{COMPACT_NUMBER_SUFFIX_REGEX})?\s+" r"(?P\$)?" - r"(?P(?:(?<=[\s$])[A-Za-z]|[A-Za-z]{2,10}))(?!\w)", + r"(?P(?:(?<=[\s$])[A-Za-z]|[A-Za-z]{2,10}))(?!\w)" + r"(?:\s+tokens?\b)?", flags=re.IGNORECASE, ) CRYPTO_AMOUNT_PREFIX_PATTERN: Final[re.Pattern[str]] = re.compile( @@ -41,6 +42,10 @@ TIMEZONE_OFFSET_SUFFIX_PATTERN: Final[re.Pattern[str]] = re.compile( r"[+-]\d", ) +TOKEN_SUFFIX_PATTERN: Final[re.Pattern[str]] = re.compile( + r"\s+tokens?\b", + flags=re.IGNORECASE, +) @dataclass(frozen=True) @@ -157,6 +162,15 @@ def _parse_amount_value(match: re.Match[str]) -> Decimal: return amount +def _get_crypto_reference_end(text: str, reference_end: int) -> int: + token_suffix_match = TOKEN_SUFFIX_PATTERN.match(text, reference_end) + + if token_suffix_match is None: + return reference_end + + return token_suffix_match.end() + + def _is_single_letter_reference_allowed( reference: str, is_dollar_prefixed: bool, @@ -257,16 +271,17 @@ def resolve_crypto_amounts_from_text( ): continue + match_end = _get_crypto_reference_end(text, coin_match.end) resolved_amounts.append( ResolvedCryptoAmount( amount=_parse_amount_value(amount_match), coin=coin_match.coin, - matched_text=text[amount_match.start():coin_match.end], + matched_text=text[amount_match.start():match_end], start=amount_match.start(), - end=coin_match.end, + end=match_end, ) ) - previous_match_end = coin_match.end + previous_match_end = match_end return resolved_amounts diff --git a/tests/crypto_converter/test_crypto_amount_parser.py b/tests/crypto_converter/test_crypto_amount_parser.py index 94d0a72..6c8d43c 100644 --- a/tests/crypto_converter/test_crypto_amount_parser.py +++ b/tests/crypto_converter/test_crypto_amount_parser.py @@ -29,6 +29,7 @@ ("1m BNB", Decimal("1000000"), "BNB", "1m BNB"), ("1kk K", Decimal("1000000"), "K", "1kk K"), ("100 000 BNB", Decimal("100000"), "BNB", "100 000 BNB"), + ("250 AEON tokens", Decimal("250"), "AEON", "250 AEON tokens"), ], ) def test_parse_crypto_amounts( @@ -45,6 +46,95 @@ def test_parse_crypto_amounts( assert parsed_amount.matched_text == expected_match +@pytest.mark.parametrize( + ("text", "expected_match"), + [ + ("250 AEON", "250 AEON"), + ("250 AEON token", "250 AEON token"), + ("250 AEON tokens", "250 AEON tokens"), + ], +) +def test_resolve_optional_token_suffix_after_ticker( + monkeypatch: pytest.MonkeyPatch, + text: str, + expected_match: str, +) -> None: + coin = ResolvedCoin("aeon", "AEON", "Aeon") + + def resolve_aeon_reference( + message_text: str, + start: int, + ) -> ResolvedCoinMatch | None: + if not message_text[start:].casefold().startswith("aeon"): + return None + + return ResolvedCoinMatch( + coin=coin, + matched_text=message_text[start:start + 4], + end=start + 4, + ) + + monkeypatch.setattr( + crypto_amount_parser, + "resolve_coin_reference_at", + resolve_aeon_reference, + ) + + resolved_amounts = resolve_crypto_amounts_from_text(text) + + assert len(resolved_amounts) == 1 + assert resolved_amounts[0].amount == Decimal("250") + assert resolved_amounts[0].coin.ticker == "AEON" + assert resolved_amounts[0].matched_text == expected_match + + +def test_resolve_crypto_amount_with_token_suffix_from_long_message( + monkeypatch: pytest.MonkeyPatch, +) -> None: + message_text = ( + "Binance Alpha is the first platform to feature AEON (AEON), with " + "Alpha debut and trading starting on July 27, 2026, at 10:00 (UTC). " + "\n\nUsers with at least 245 Binance Alpha Points can claim an " + "airdrop of 250 AEON tokens on a first-come, first-served basis. " + "If the reward pool is not fully distributed, the score threshold " + "will automatically decrease by 5 points every 5 minutes.\n\n" + "Please note that claiming the airdrop will consume 15 Binance " + "Alpha Points. Users must confirm their claim on the Alpha Events " + "page within 24 hours; otherwise, it will be deemed that users have " + "given up claiming the airdrop." + ) + coin = ResolvedCoin("aeon", "AEON", "Aeon") + + def resolve_aeon_reference( + message_text: str, + start: int, + ) -> ResolvedCoinMatch | None: + if not message_text[start:].casefold().startswith("aeon"): + return None + + return ResolvedCoinMatch( + coin=coin, + matched_text=message_text[start:start + 4], + end=start + 4, + ) + + monkeypatch.setattr( + crypto_amount_parser, + "resolve_top_ranked_coin_reference_at", + resolve_aeon_reference, + ) + + resolved_amounts = resolve_crypto_amounts_from_text( + message_text, + top_ranked_only=True, + ) + + assert len(resolved_amounts) == 1 + assert resolved_amounts[0].amount == Decimal("250") + assert resolved_amounts[0].coin.ticker == "AEON" + assert resolved_amounts[0].matched_text == "250 AEON tokens" + + @pytest.mark.parametrize( "text", [ diff --git a/tests/time_converter/test_utc_time_parser.py b/tests/time_converter/test_utc_time_parser.py index b72a5a9..999cfaf 100644 --- a/tests/time_converter/test_utc_time_parser.py +++ b/tests/time_converter/test_utc_time_parser.py @@ -17,6 +17,7 @@ ("10:00 UTC", "UTC", 10, 0), ("10:00UTC", "UTC", 10, 0), ("10 UTC", "UTC", 10, 0), + ("10:00 (UTC)", "UTC", 10, 0), ("10:30 cet", "CET", 10, 30), ("10 CEST", "CEST", 10, 0), ("10:45kyiv", "KYIV", 10, 45), @@ -94,6 +95,28 @@ def test_parse_utc_time_from_text_skips_non_utc_matches() -> None: assert parsed_datetime.minute == 0 +def test_parse_parenthesized_utc_time_from_long_message() -> None: + message_text = ( + "Binance Alpha is the first platform to feature AEON (AEON), with " + "Alpha debut and trading starting on July 27, 2026, at 10:00 (UTC). " + "\n\nUsers with at least 245 Binance Alpha Points can claim an " + "airdrop of 250 AEON tokens on a first-come, first-served basis. " + "If the reward pool is not fully distributed, the score threshold " + "will automatically decrease by 5 points every 5 minutes.\n\n" + "Please note that claiming the airdrop will consume 15 Binance " + "Alpha Points. Users must confirm their claim on the Alpha Events " + "page within 24 hours; otherwise, it will be deemed that users have " + "given up claiming the airdrop." + ) + + parsed_times = parse_times_from_text(message_text, limit=5) + + assert len(parsed_times) == 1 + assert parsed_times[0].timezone_label == "UTC" + assert parsed_times[0].source_datetime.hour == 10 + assert parsed_times[0].source_datetime.minute == 0 + + @pytest.mark.parametrize( "text", [ diff --git a/time_converter/utc_time_parser.py b/time_converter/utc_time_parser.py index ed3b918..fc8b638 100644 --- a/time_converter/utc_time_parser.py +++ b/time_converter/utc_time_parser.py @@ -13,7 +13,9 @@ # Time parsing NAMED_TIME_PATTERN: Final[re.Pattern[str]] = re.compile( r"(?(?:[01]?\d|2[0-3]))" - r"(?::(?P[0-5]\d))? ?(?PUTC|CEST|CET|KYIV)\b" + r"(?::(?P[0-5]\d))?" + r"(?: ?(?PUTC|CEST|CET|KYIV)\b" + r"| ?\((?PUTC|CEST|CET|KYIV)\))" r"(?![+-])", flags=re.IGNORECASE, ) @@ -41,7 +43,9 @@ def _parse_named_time_match(match: re.Match[str]) -> Optional[ParsedTime]: hour = int(match.group("hour")) minute_group = match.group("minute") minute = int(minute_group) if minute_group is not None else 0 - timezone_label = match.group("timezone").upper() + timezone_label = ( + match.group("timezone") or match.group("parenthesized_timezone") + ).upper() timezone = TIMEZONES_BY_LABEL[timezone_label] return ParsedTime(