refactor(string): split utilities by responsibility

This commit is contained in:
jxxghp
2026-08-15 08:03:55 +08:00
parent a2117bafc8
commit 96118e73e7
99 changed files with 1608 additions and 1424 deletions
+36
View File
@@ -46,6 +46,7 @@ RETIRED_CANONICAL_FILES = (
"app/security/url_safety.py",
"app/domain/mediaserver.py",
"app/domain/nfo.py",
"app/domain/string.py",
"app/log.py",
"app/foundation/diagnostics.py",
"app/infrastructure/log.py",
@@ -226,6 +227,16 @@ def test_legacy_roots_contain_no_python_sources():
assert leftovers == []
def test_legacy_source_directories_do_not_exist():
"""core/helper/utils 物理目录应完全退役,旧导入只由虚拟兼容包解析。"""
leftovers = [
root_name
for root_name in ("core", "helper", "utils")
if (APP_ROOT / root_name).exists()
]
assert leftovers == []
def test_retired_canonical_filenames_do_not_return():
"""能力包应使用包内语境明确的短文件名,避免再次出现冗余角色后缀。"""
leftovers = [
@@ -326,6 +337,31 @@ def test_capability_packages_do_not_import_forbidden_upper_layers():
assert violations == {}
def test_site_domain_uses_foundation_dom_boundary():
"""站点领域规则应依赖 DOM 原语,不得重新耦合聚合字符串工具。"""
modules = _discover_modules()
dependencies = _resolve_imports(
"app.domain.site",
modules["app.domain.site"],
set(modules),
)
assert "app.foundation.dom" in dependencies
assert "app.domain.string" not in dependencies
def test_host_code_does_not_use_string_utils_facade():
"""聚合 StringUtils 只服务插件兼容,宿主实现必须使用拆分后的能力。"""
violations: list[str] = []
for path in APP_ROOT.rglob("*.py"):
relative = path.relative_to(APP_ROOT)
if relative.parts[0] in {"plugins", "sdk"}:
continue
tree = ast.parse(path.read_text(encoding="utf-8-sig"), filename=str(path))
if any(isinstance(node, ast.Name) and node.id == "StringUtils" for node in ast.walk(tree)):
violations.append(str(relative))
assert violations == []
def test_runtime_log_is_a_dependency_leaf():
"""底层可引用运行时日志,但日志模块本身不得反向导入应用模块。"""
modules = _discover_modules()
+29 -22
View File
@@ -101,6 +101,13 @@ def _load_transmission_module():
app_module.__path__ = []
core_module = types.ModuleType("app.core")
core_module.__path__ = []
domain_module = types.ModuleType("app.domain")
domain_module.__path__ = []
foundation_module = types.ModuleType("app.foundation")
foundation_module.__path__ = []
torrent_rules_module = types.ModuleType("app.domain.torrent")
size_tools_module = types.ModuleType("app.foundation.size")
temporal_tools_module = types.ModuleType("app.foundation.temporal")
cache_module = types.ModuleType("app.runtime.cache")
modules_module = types.ModuleType("app.modules")
modules_module.__path__ = []
@@ -112,9 +119,6 @@ def _load_transmission_module():
config_module = types.ModuleType("app.runtime.config")
metainfo_module = types.ModuleType("app.domain.metainfo")
log_module = types.ModuleType("app.runtime.log")
utils_module = types.ModuleType("app.utils")
utils_module.__path__ = []
string_module = types.ModuleType("app.domain.string")
transmission_rpc_module = types.ModuleType("transmission_rpc")
torrentool_module = types.ModuleType("torrentool")
torrentool_module.__path__ = []
@@ -172,22 +176,17 @@ def _load_transmission_module():
self.season_episode = ""
self.episode_list = []
class _StringUtils:
@staticmethod
def is_magnet_link(value):
return isinstance(value, str) and value.startswith("magnet:")
def _is_magnet_link(value):
"""按生产领域规则识别测试磁力链接。"""
return isinstance(value, str) and value.startswith("magnet:")
@staticmethod
def generate_random_str(_length):
return "tmp-tag-01"
def _format_size(value):
"""返回隔离测试需要的简化容量文本。"""
return str(value)
@staticmethod
def str_filesize(value):
return str(value)
@staticmethod
def str_secends(value):
return str(value)
def _format_duration(value):
"""返回隔离测试需要的简化时长文本。"""
return str(value)
class _FileCache:
def get(self, *_args, **_kwargs):
@@ -211,28 +210,38 @@ def _load_transmission_module():
log_module.logger = _Logger()
modules_module._ModuleBase = _ModuleBase
modules_module._DownloaderBase = _DownloaderBase
string_module.StringUtils = _StringUtils
torrent_rules_module.is_magnet_link = _is_magnet_link
size_tools_module.format_compact_size = _format_size
temporal_tools_module.format_duration = _format_duration
transmission_rpc_module.File = object
torrentool_torrent_module.Torrent = SimpleNamespace(
from_string=lambda _content: SimpleNamespace(name="test", total_size=1)
)
app_module.core = core_module
app_module.domain = domain_module
app_module.foundation = foundation_module
app_module.modules = modules_module
app_module.schemas = schemas_module
app_module.utils = utils_module
domain_module.torrent = torrent_rules_module
foundation_module.size = size_tools_module
foundation_module.temporal = temporal_tools_module
core_module.cache = cache_module
core_module.config = config_module
core_module.metainfo = metainfo_module
modules_module.transmission = transmission_package_module
transmission_package_module.transmission = transmission_client_module
schemas_module.types = schema_types_module
utils_module.string = string_module
torrentool_module.torrent = torrentool_torrent_module
stub_modules = {
"app": app_module,
"app.core": core_module,
"app.domain": domain_module,
"app.domain.torrent": torrent_rules_module,
"app.foundation": foundation_module,
"app.foundation.size": size_tools_module,
"app.foundation.temporal": temporal_tools_module,
"app.runtime.cache": cache_module,
"app.runtime.config": config_module,
"app.domain.metainfo": metainfo_module,
@@ -242,8 +251,6 @@ def _load_transmission_module():
"app.modules.transmission.transmission": transmission_client_module,
"app.schemas": schemas_module,
"app.schemas.types": schema_types_module,
"app.utils": utils_module,
"app.domain.string": string_module,
"transmission_rpc": transmission_rpc_module,
"torrentool": torrentool_module,
"torrentool.torrent": torrentool_torrent_module,
+3 -3
View File
@@ -1,6 +1,6 @@
# -*- coding: utf-8 -*-
from app.modules.indexer.parser.nexus_audiences import NexusAudiencesSiteUserInfo
from app.domain.string import StringUtils
from app.foundation.size import parse_size
def test_audiences_userbar_metrics_override_generic_nexus_regex():
@@ -38,8 +38,8 @@ def test_audiences_userbar_metrics_override_generic_nexus_regex():
assert parser.userid == "18978"
assert parser.username == "jxxghp"
assert parser.user_level == "(江湖儿女)Elite User"
assert parser.upload == StringUtils.num_filesize("10.150 TB")
assert parser.download == StringUtils.num_filesize("3.624 TB")
assert parser.upload == parse_size("10.150 TB")
assert parser.download == parse_size("3.624 TB")
assert parser.ratio == 2.801
assert parser.bonus == 1973896.2
assert parser.seeding == 355
+5 -3
View File
@@ -19,13 +19,15 @@ def test_sdk_exports_canonical_plugin_interfaces():
from app.domain.meta.metamusic import MetaMusic as CanonicalMetaMusic
from app.domain.metainfo import MetaInfo as CanonicalMetaInfo
from app.domain.scraper import NfoReader as CanonicalNfoReader
from app.domain.string import StringUtils as CanonicalStringUtils
LegacyDomainStringUtils = importlib.import_module(
"app.domain.string"
).StringUtils
from app.foundation.crypto import CryptoJsUtils
from app.runtime.extensions.module_manager import ModuleManager as CanonicalModuleManager
from app.runtime.extensions.plugin_manager import PluginManager as CanonicalPluginManager
from app.adapters.network.http import RequestUtils as CanonicalRequestUtils
from app.application.rss import RssHelper as CanonicalRssHelper
from app.application.site.sites import SitesHelper as CanonicalSitesHelper
from app.application.site.sites import SitesHelper as CanonicalSitesHelper # pylint: disable=no-name-in-module
from app.runtime.cache import Cache as CanonicalCache
from app.runtime.cache import cached as canonical_cached
from app.runtime.config import settings as canonical_settings
@@ -49,7 +51,7 @@ def test_sdk_exports_canonical_plugin_interfaces():
assert RssHelper is CanonicalRssHelper
assert SitesHelper is CanonicalSitesHelper
assert NotificationHelper is CanonicalNotificationHelper
assert UtilityStringUtils is CanonicalStringUtils
assert UtilityStringUtils is LegacyDomainStringUtils
assert decrypt is CryptoJsUtils.decrypt
assert encrypt is CryptoJsUtils.encrypt
assert ModuleManager is CanonicalModuleManager
+45 -26
View File
@@ -13,8 +13,15 @@ def _load_qbittorrent_modules():
app_module.__path__ = []
core_module = types.ModuleType("app.core")
core_module.__path__ = []
utils_module = types.ModuleType("app.utils")
utils_module.__path__ = []
domain_module = types.ModuleType("app.domain")
domain_module.__path__ = []
foundation_module = types.ModuleType("app.foundation")
foundation_module.__path__ = []
torrent_rules_module = types.ModuleType("app.domain.torrent")
size_tools_module = types.ModuleType("app.foundation.size")
temporal_tools_module = types.ModuleType("app.foundation.temporal")
text_tools_module = types.ModuleType("app.foundation.text")
url_tools_module = types.ModuleType("app.foundation.url")
modules_module = types.ModuleType("app.modules")
modules_module.__path__ = []
qbittorrent_package_module = types.ModuleType("app.modules.qbittorrent")
@@ -25,7 +32,6 @@ def _load_qbittorrent_modules():
metainfo_module = types.ModuleType("app.domain.metainfo")
schemas_module = types.ModuleType("app.schemas")
schema_types_module = types.ModuleType("app.schemas.types")
string_module = types.ModuleType("app.domain.string")
torrentool_module = types.ModuleType("torrentool")
torrentool_module.__path__ = []
torrentool_torrent_module = types.ModuleType("torrentool.torrent")
@@ -46,28 +52,27 @@ def _load_qbittorrent_modules():
def error(self, *_args, **_kwargs):
pass
class _StringUtils:
@staticmethod
def get_domain_address(address, prefix=False):
return address, 8080
def _is_magnet_link(value):
"""按生产领域规则识别测试磁力链接。"""
if isinstance(value, bytes):
return value.startswith(b"magnet:")
return isinstance(value, str) and value.startswith("magnet:")
@staticmethod
def is_magnet_link(value):
if isinstance(value, bytes):
return value.startswith(b"magnet:")
return isinstance(value, str) and value.startswith("magnet:")
def _parse_address(address, include_scheme=False):
"""返回隔离测试使用的主机和固定端口。"""
return address, 8080
@staticmethod
def generate_random_str(_length):
return "tmp-tag-01"
def _random_string(_length):
"""生成可断言的固定临时标签。"""
return "tmp-tag-01"
@staticmethod
def str_filesize(value):
return str(value)
def _format_size(value):
"""返回隔离测试需要的简化容量文本。"""
return str(value)
@staticmethod
def str_secends(value):
return str(value)
def _format_duration(value):
"""返回隔离测试需要的简化时长文本。"""
return str(value)
class _FileCache:
def get(self, *_args, **_kwargs):
@@ -131,7 +136,11 @@ def _load_qbittorrent_modules():
schema_types_module.DownloadTaskState = DownloadTaskState
schema_types_module.ModuleType = ModuleType
schema_types_module.DownloaderType = DownloaderType
string_module.StringUtils = _StringUtils
torrent_rules_module.is_magnet_link = _is_magnet_link
url_tools_module.parse_address = _parse_address
text_tools_module.random_string = _random_string
size_tools_module.format_compact_size = _format_size
temporal_tools_module.format_duration = _format_duration
modules_module._ModuleBase = _ModuleBase
modules_module._DownloaderBase = _DownloaderBase
torrentool_torrent_module.Torrent = _Torrent
@@ -145,14 +154,19 @@ def _load_qbittorrent_modules():
qbittorrentapi_transfer_module.TransferInfoDictionary = dict
app_module.core = core_module
app_module.domain = domain_module
app_module.foundation = foundation_module
app_module.log = log_module
app_module.modules = modules_module
app_module.schemas = schemas_module
app_module.utils = utils_module
domain_module.torrent = torrent_rules_module
foundation_module.size = size_tools_module
foundation_module.temporal = temporal_tools_module
foundation_module.text = text_tools_module
foundation_module.url = url_tools_module
core_module.cache = cache_module
core_module.config = config_module
core_module.metainfo = metainfo_module
utils_module.string = string_module
schemas_module.types = schema_types_module
modules_module.qbittorrent = qbittorrent_package_module
torrentool_module.torrent = torrentool_torrent_module
@@ -160,6 +174,13 @@ def _load_qbittorrent_modules():
stub_modules = {
"app": app_module,
"app.core": core_module,
"app.domain": domain_module,
"app.domain.torrent": torrent_rules_module,
"app.foundation": foundation_module,
"app.foundation.size": size_tools_module,
"app.foundation.temporal": temporal_tools_module,
"app.foundation.text": text_tools_module,
"app.foundation.url": url_tools_module,
"app.runtime.cache": cache_module,
"app.runtime.config": config_module,
"app.domain.metainfo": metainfo_module,
@@ -168,8 +189,6 @@ def _load_qbittorrent_modules():
"app.modules.qbittorrent": qbittorrent_package_module,
"app.schemas": schemas_module,
"app.schemas.types": schema_types_module,
"app.utils": utils_module,
"app.domain.string": string_module,
"qbittorrentapi": qbittorrentapi_module,
"qbittorrentapi.client": qbittorrentapi_client_module,
"qbittorrentapi.transfer": qbittorrentapi_transfer_module,
-37
View File
@@ -1,7 +1,5 @@
from importlib.machinery import EXTENSION_SUFFIXES, PathFinder
from pathlib import Path
from app.application.site import _include_legacy_resource_directory
from app.runtime.config import settings
from app.adapters.system.resource import (
ResourceHelper,
@@ -13,41 +11,6 @@ from app.startup import modules_initializer
ROOT_DIR = Path(__file__).resolve().parents[1]
def test_legacy_docker_updater_resource_directory_remains_importable(tmp_path):
"""旧镜像把资源写入 helper 时,canonical 站点包仍应找到对应扩展。"""
package_dir = tmp_path / "app" / "application" / "site"
legacy_dir = tmp_path / "app" / "helper"
package_dir.mkdir(parents=True)
legacy_dir.mkdir(parents=True)
extension_path = legacy_dir / f"sites{EXTENSION_SUFFIXES[0]}"
extension_path.touch()
package_paths = [str(package_dir)]
_include_legacy_resource_directory(package_paths, package_dir)
assert (ROOT_DIR / "app" / "helper" / ".resource-compat").is_file()
assert package_paths == [str(package_dir), str(legacy_dir)]
spec = PathFinder.find_spec("app.application.site.sites", package_paths)
assert spec is not None
assert spec.origin == str(extension_path)
def test_canonical_site_extension_takes_priority_over_legacy_directory(tmp_path):
"""canonical 扩展存在时不得把旧资源目录加入站点包搜索路径。"""
package_dir = tmp_path / "app" / "application" / "site"
legacy_dir = tmp_path / "app" / "helper"
package_dir.mkdir(parents=True)
legacy_dir.mkdir(parents=True)
extension_name = f"sites{EXTENSION_SUFFIXES[0]}"
(package_dir / extension_name).touch()
(legacy_dir / extension_name).touch()
package_paths = [str(package_dir)]
_include_legacy_resource_directory(package_paths, package_dir)
assert package_paths == [str(package_dir)]
def test_resource_helper_uses_v3_only():
"""在线资源更新器必须只请求 V3 清单、目录和站点索引文件。"""
assert settings.VERSION_FLAG == "v3"
+42
View File
@@ -0,0 +1,42 @@
from lxml import etree
from app.domain.site import SiteUtils
from app.foundation.dom import DomUtils
from app.sdk.string import StringUtils
def test_dom_child_element_check_preserves_existing_semantics():
"""DOM 基础判断应区分空树和至少包含一个子元素的树。"""
empty_tree = etree.HTML("<html></html>")
populated_tree = etree.HTML("<html><body></body></html>")
assert DomUtils.has_child_elements(None) is False
assert DomUtils.has_child_elements(empty_tree) is False
assert DomUtils.has_child_elements(populated_tree) is True
def test_plugin_string_facade_delegates_to_dom_primitive():
"""存量 StringUtils 调用应继续获得与 DOM 原语一致的结果。"""
html = etree.HTML("<html><body></body></html>")
assert StringUtils.is_valid_html_element(html) is DomUtils.has_child_elements(html)
def test_site_login_state_is_derived_from_html_markers():
"""站点登录规则应识别退出入口并拒绝密码登录页。"""
logged_in_html = '<html><body><a href="/logout.php">退出</a></body></html>'
login_form_html = '<html><body><input type="password"></body></html>'
assert SiteUtils.is_logged_in(logged_in_html) is True
assert SiteUtils.is_logged_in(login_form_html) is False
assert SiteUtils.is_logged_in("") is False
def test_site_checkin_state_is_derived_from_html_markers():
"""站点签到规则应把仍存在签到入口的页面识别为未签到。"""
pending_html = '<html><body><a href="/attendance.php">签到</a></body></html>'
completed_html = '<html><body><a href="/logout.php">退出</a></body></html>'
assert SiteUtils.is_checkin(pending_html) is False
assert SiteUtils.is_checkin(completed_html) is True
assert SiteUtils.is_checkin("") is False
+19 -18
View File
@@ -1,26 +1,27 @@
from unittest import TestCase
from app.domain.string import StringUtils
from app.domain.title import is_media_title_like
class StringUtilsTest(TestCase):
class MediaTitleTest(TestCase):
"""验证媒体标题候选规则。"""
def test_is_media_title_like_true(self):
self.assertTrue(StringUtils.is_media_title_like("盗梦空间"))
self.assertTrue(StringUtils.is_media_title_like("The Lord of the Rings"))
self.assertTrue(StringUtils.is_media_title_like("庆余年 第2季"))
self.assertTrue(StringUtils.is_media_title_like("The Office S01E01"))
self.assertTrue(StringUtils.is_media_title_like("权力的游戏 Game of Thrones"))
self.assertTrue(StringUtils.is_media_title_like("Spider-Man: No Way Home 2021"))
self.assertTrue(is_media_title_like("盗梦空间"))
self.assertTrue(is_media_title_like("The Lord of the Rings"))
self.assertTrue(is_media_title_like("庆余年 第2季"))
self.assertTrue(is_media_title_like("The Office S01E01"))
self.assertTrue(is_media_title_like("权力的游戏 Game of Thrones"))
self.assertTrue(is_media_title_like("Spider-Man: No Way Home 2021"))
def test_is_media_title_like_false(self):
self.assertFalse(StringUtils.is_media_title_like(""))
self.assertFalse(StringUtils.is_media_title_like(" "))
self.assertFalse(StringUtils.is_media_title_like("a"))
self.assertFalse(StringUtils.is_media_title_like("第2季"))
self.assertFalse(StringUtils.is_media_title_like("S01E01"))
self.assertFalse(StringUtils.is_media_title_like("#推荐电影"))
self.assertFalse(StringUtils.is_media_title_like("请帮我推荐一部电影"))
self.assertFalse(StringUtils.is_media_title_like("盗梦空间怎么样?"))
self.assertFalse(StringUtils.is_media_title_like("我想看盗梦空间"))
self.assertFalse(StringUtils.is_media_title_like("继续"))
self.assertFalse(is_media_title_like(""))
self.assertFalse(is_media_title_like(" "))
self.assertFalse(is_media_title_like("a"))
self.assertFalse(is_media_title_like("第2季"))
self.assertFalse(is_media_title_like("S01E01"))
self.assertFalse(is_media_title_like("#推荐电影"))
self.assertFalse(is_media_title_like("请帮我推荐一部电影"))
self.assertFalse(is_media_title_like("盗梦空间怎么样?"))
self.assertFalse(is_media_title_like("我想看盗梦空间"))
self.assertFalse(is_media_title_like("继续"))
+120
View File
@@ -0,0 +1,120 @@
import importlib
import inspect
from lxml import etree
from app.sdk.string import StringUtils
EXPECTED_METHODS = {
"clear",
"clear_file_name",
"clear_upper",
"compare_version",
"count_words",
"diff_time_str",
"escape_markdown",
"find_common_prefix",
"format_ep",
"format_size",
"format_timestamp",
"generate_random_str",
"get_base_url",
"get_domain_address",
"get_idlist",
"get_keyword",
"get_time",
"get_url_domain",
"get_url_host",
"get_url_netloc",
"get_url_sld",
"is_all_chinese",
"is_chinese",
"is_english_word",
"is_japanese",
"is_korean",
"is_link",
"is_magnet_link",
"is_media_title_like",
"is_number",
"is_valid_html_element",
"md5_hash",
"natural_sort_key",
"num_filesize",
"safe_strip",
"split_text",
"str_amount",
"str_filesize",
"str_float",
"str_from_cookiejar",
"str_int",
"str_secends",
"str_series",
"str_timehours",
"str_timelong",
"str_title",
"str_to_timestamp",
"to_bool",
"unify_datetime_str",
"url_equal",
}
def test_string_utils_keeps_complete_plugin_method_surface():
"""SDK 门面必须保留拆分前全部静态方法名称。"""
assert EXPECTED_METHODS <= set(dir(StringUtils))
def test_legacy_string_modules_share_sdk_facade_identity():
"""旧 utils/domain 路径与 SDK 应解析到同一个轻量兼容模块。"""
sdk_module = importlib.import_module("app.sdk.string")
legacy_utils = importlib.import_module("app.utils.string")
legacy_domain = importlib.import_module("app.domain.string")
assert legacy_utils is sdk_module
assert legacy_domain is sdk_module
assert legacy_utils.StringUtils is StringUtils
assert legacy_domain.StringUtils is StringUtils
def test_string_utils_preserves_legacy_keyword_arguments():
"""插件按旧参数名调用时应正确转交到拆分后的实现。"""
assert StringUtils.clear(text="A.B C", replace_word="-", allow_space=True) == "A-B C"
assert StringUtils.str_filesize(size=1024 ** 3, pre=2) == "1.0G"
assert StringUtils.url_equal(url1="https://www.example.com", url2="example.com") is True
assert StringUtils.generate_random_str(randomlength=8)
assert StringUtils.to_bool(text="", default_val=True) is True
assert StringUtils.str_amount(amount=1234, curr="¥") == "¥1,234"
assert StringUtils.get_domain_address(
address="example.com:8080", prefix=True
) == ("http://example.com", 8080)
assert StringUtils.compare_version(
v1="1.2.0", compare_type="<", v2="1.3.0"
) is True
def test_string_utils_preserves_legacy_method_signatures():
"""反射静态方法签名时也应继续看到插件熟悉的旧参数名。"""
assert list(inspect.signature(StringUtils.clear).parameters) == [
"text",
"replace_word",
"allow_space",
]
assert list(inspect.signature(StringUtils.compare_version).parameters) == [
"v1",
"compare_type",
"v2",
"verbose",
]
def test_string_utils_routes_representative_capabilities():
"""容量、站点、媒体、剧集、种子和 DOM 能力应保持历史结果。"""
assert StringUtils.num_filesize("10.150 TB") == 11160043021926
assert StringUtils.get_url_domain("https://u2.dmhy.org/torrents.php") == "u2.dmhy.org"
assert StringUtils.is_media_title_like("The Office S01E01") is True
assert StringUtils.format_ep([1, 2, 3, 5]) == "E01-E03、E05"
assert StringUtils.is_magnet_link("magnet:?xt=urn:btih:abc") is True
assert StringUtils.is_valid_html_element(
etree.HTML("<html><body></body></html>")
) is True
+3 -3
View File
@@ -1,7 +1,7 @@
# -*- coding: utf-8 -*-
from app.modules.indexer import parser as parser_module
from app.modules.indexer.parser.torrent_leech import TorrentLeechSiteUserInfo
from app.domain.string import StringUtils
from app.foundation.size import parse_size
PROFILE_VIEW_HTML = """
@@ -93,8 +93,8 @@ def test_torrent_leech_refresh_prefers_topbar_user_and_parses_profile_once(monke
assert parser.userid == "example_user"
assert parser.username == "example_user"
assert parser.upload == StringUtils.num_filesize("41.54 GB")
assert parser.download == StringUtils.num_filesize("10.16 GB")
assert parser.upload == parse_size("41.54 GB")
assert parser.download == parse_size("10.16 GB")
assert parser.ratio == 4.089
assert parser.user_level == "Registered"
assert parser.join_at == "2022-09-04 00:00:00"