From 4e042ba5979e37f0329510f3943770d7a074a045 Mon Sep 17 00:00:00 2001 From: xiaoxia Date: Wed, 22 Jul 2026 16:36:50 +0800 Subject: [PATCH] =?UTF-8?q?test(unit):=20P3-1=E7=AC=AC=E4=B8=83=E6=B3=A2?= =?UTF-8?q?=20entities=E9=A2=86=E5=9F=9F=E6=A8=A1=E5=9D=97=E5=8D=95?= =?UTF-8?q?=E5=85=83=E6=B5=8B=E8=AF=95(80=E4=B8=AA=E7=94=A8=E4=BE=8B)=20(#?= =?UTF-8?q?709)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/unit/test_entities_domain.py | 732 +++++++++++++++++++++++++++++ 1 file changed, 732 insertions(+) create mode 100755 tests/unit/test_entities_domain.py diff --git a/tests/unit/test_entities_domain.py b/tests/unit/test_entities_domain.py new file mode 100755 index 000000000..29fde2533 --- /dev/null +++ b/tests/unit/test_entities_domain.py @@ -0,0 +1,732 @@ +"""entities 领域模块单元测试 - P3-1 第七波 + +覆盖 User、Project、AssetLibrary、Asset、IngestJob 五个数据类 ++ AssetLibraryKind/IngestJobStatus/AssetStatus/ClassificationStatus 四个枚举 +""" + +from __future__ import annotations + +from datetime import datetime, timezone + +import pytest + + +class TestAssetLibraryKind: + """AssetLibraryKind 枚举测试""" + + def test_all_kinds_exist(self): + from packages.domain.entities import AssetLibraryKind + + assert AssetLibraryKind.VIDEO == "video" + assert AssetLibraryKind.VOICE == "voice" + assert AssetLibraryKind.IMAGE == "image" + + def test_is_string_type(self): + from packages.domain.entities import AssetLibraryKind + + assert isinstance(AssetLibraryKind.VIDEO, str) + assert AssetLibraryKind.VIDEO + "" == "video" + + def test_total_count(self): + from packages.domain.entities import AssetLibraryKind + + assert len(AssetLibraryKind) == 3 + + +class TestIngestJobStatus: + """IngestJobStatus 枚举测试""" + + def test_all_statuses_exist(self): + from packages.domain.entities import IngestJobStatus + + assert IngestJobStatus.PENDING == "pending" + assert IngestJobStatus.PROCESSING == "processing" + assert IngestJobStatus.COMPLETED == "completed" + assert IngestJobStatus.FAILED == "failed" + + def test_is_string_type(self): + from packages.domain.entities import IngestJobStatus + + assert isinstance(IngestJobStatus.PENDING, str) + + def test_total_count(self): + from packages.domain.entities import IngestJobStatus + + assert len(IngestJobStatus) == 4 + + +class TestAssetStatus: + """AssetStatus 枚举 + _missing_ 兼容逻辑测试""" + + def test_standard_values(self): + from packages.domain.entities import AssetStatus + + assert AssetStatus.UPLOADING == "uploading" + assert AssetStatus.READY == "ready" + assert AssetStatus.PROCESSING == "processing" + assert AssetStatus.ERROR == "error" + assert AssetStatus.DELETED == "deleted" + + def test_missing_uploaded_maps_to_ready(self): + """历史值 uploaded → READY""" + from packages.domain.entities import AssetStatus + + assert AssetStatus("uploaded") == AssetStatus.READY + + def test_missing_success_maps_to_ready(self): + from packages.domain.entities import AssetStatus + + assert AssetStatus("success") == AssetStatus.READY + + def test_missing_done_maps_to_ready(self): + from packages.domain.entities import AssetStatus + + assert AssetStatus("done") == AssetStatus.READY + + def test_missing_upload_variants_map_to_uploading(self): + from packages.domain.entities import AssetStatus + + assert AssetStatus("upload") == AssetStatus.UPLOADING + assert AssetStatus("uploading_start") == AssetStatus.UPLOADING + assert AssetStatus("upload_start") == AssetStatus.UPLOADING + + def test_missing_failed_variants_map_to_error(self): + from packages.domain.entities import AssetStatus + + assert AssetStatus("failed") == AssetStatus.ERROR + assert AssetStatus("fail") == AssetStatus.ERROR + assert AssetStatus("err") == AssetStatus.ERROR + + def test_missing_process_variants_map_to_processing(self): + from packages.domain.entities import AssetStatus + + assert AssetStatus("process") == AssetStatus.PROCESSING + assert AssetStatus("running") == AssetStatus.PROCESSING + assert AssetStatus("run") == AssetStatus.PROCESSING + + def test_missing_unknown_falls_back_to_ready(self): + """完全未知的值兜底为 READY,不阻塞业务""" + from packages.domain.entities import AssetStatus + + assert AssetStatus("weird_status") == AssetStatus.READY + assert AssetStatus("deprecated_state") == AssetStatus.READY + + def test_missing_case_insensitive(self): + from packages.domain.entities import AssetStatus + + assert AssetStatus("UPLOADED") == AssetStatus.READY + assert AssetStatus("Failed") == AssetStatus.ERROR + + def test_missing_with_spaces(self): + from packages.domain.entities import AssetStatus + + assert AssetStatus(" uploaded ") == AssetStatus.READY + + def test_missing_non_string_returns_ready(self): + """非字符串输入也兜底,不抛异常""" + from packages.domain.entities import AssetStatus + + assert AssetStatus(None) == AssetStatus.READY # type: ignore[arg-type] + assert AssetStatus(123) == AssetStatus.READY # type: ignore[arg-type] + + +class TestClassificationStatus: + """ClassificationStatus 枚举 + _missing_ 兼容逻辑测试""" + + def test_standard_values(self): + from packages.domain.entities import ClassificationStatus + + assert ClassificationStatus.PENDING == "pending" + assert ClassificationStatus.PROCESSING == "processing" + assert ClassificationStatus.COMPLETED == "completed" + assert ClassificationStatus.FAILED == "failed" + + def test_missing_done_maps_to_completed(self): + """历史值 done → COMPLETED""" + from packages.domain.entities import ClassificationStatus + + assert ClassificationStatus("done") == ClassificationStatus.COMPLETED + + def test_missing_success_maps_to_completed(self): + from packages.domain.entities import ClassificationStatus + + assert ClassificationStatus("success") == ClassificationStatus.COMPLETED + + def test_missing_finished_maps_to_completed(self): + from packages.domain.entities import ClassificationStatus + + assert ClassificationStatus("finished") == ClassificationStatus.COMPLETED + + def test_missing_fail_variants_map_to_failed(self): + from packages.domain.entities import ClassificationStatus + + assert ClassificationStatus("fail") == ClassificationStatus.FAILED + assert ClassificationStatus("error") == ClassificationStatus.FAILED + assert ClassificationStatus("err") == ClassificationStatus.FAILED + + def test_missing_process_variants_map_to_processing(self): + from packages.domain.entities import ClassificationStatus + + assert ClassificationStatus("process") == ClassificationStatus.PROCESSING + assert ClassificationStatus("running") == ClassificationStatus.PROCESSING + + def test_missing_unknown_falls_back_to_pending(self): + """完全未知的值兜底为 PENDING""" + from packages.domain.entities import ClassificationStatus + + assert ClassificationStatus("unknown_state") == ClassificationStatus.PENDING + + def test_missing_case_insensitive_and_whitespace(self): + from packages.domain.entities import ClassificationStatus + + assert ClassificationStatus("DONE") == ClassificationStatus.COMPLETED + assert ClassificationStatus(" Success ") == ClassificationStatus.COMPLETED + + def test_missing_non_string_returns_pending(self): + from packages.domain.entities import ClassificationStatus + + assert ClassificationStatus(None) == ClassificationStatus.PENDING # type: ignore[arg-type] + + +class TestUser: + """User 数据类测试""" + + def test_create_minimal_user(self): + from packages.domain.entities import User + + user = User(id="user_123", email="test@example.com", display_name="测试用户") + assert user.id == "user_123" + assert user.email == "test@example.com" + assert user.display_name == "测试用户" + assert user.username == "" + + def test_default_values(self): + from packages.domain.entities import User + + user = User(id="u1", email="a@b.com", display_name="Test") + assert user.password_hash == "" + assert user.email_verified is False + assert user.email_verification_token is None + assert user.password_reset_token is None + assert user.subscription_plan == "free" + assert user.subscription_status == "active" + assert user.max_projects == 3 + assert user.max_storage_gb == 10 + assert user.used_storage_gb == 0.0 + assert user.is_admin is False + assert user.wechat_openid is None + assert user.phone is None + assert user.phone_verified is False + + def test_with_wechat_binding(self): + from packages.domain.entities import User + + user = User( + id="u1", + email="wx_user@wechat.local", + display_name="微信用户", + wechat_openid="o123456789", + wechat_unionid="u987654321", + ) + assert user.wechat_openid == "o123456789" + assert user.wechat_unionid == "u987654321" + + def test_with_phone_binding(self): + from packages.domain.entities import User + + user = User( + id="u1", + email="a@b.com", + display_name="Test", + phone="13800138000", + phone_verified=True, + ) + assert user.phone == "13800138000" + assert user.phone_verified is True + + def test_admin_user(self): + from packages.domain.entities import User + + user = User(id="admin", email="admin@example.com", display_name="Admin", is_admin=True) + assert user.is_admin is True + + def test_pro_subscription(self): + from packages.domain.entities import User + + user = User( + id="u1", + email="a@b.com", + display_name="Pro", + subscription_plan="pro", + max_projects=100, + max_storage_gb=100, + ) + assert user.subscription_plan == "pro" + assert user.max_projects == 100 + assert user.max_storage_gb == 100 + + def test_has_created_at(self): + from packages.domain.entities import User + + before = datetime.now(timezone.utc) + user = User(id="u1", email="a@b.com", display_name="Test") + after = datetime.now(timezone.utc) + assert before <= user.created_at <= after + + +class TestProject: + """Project 数据类 + 业务方法测试""" + + def test_create_project(self): + from packages.domain.entities import Project + + project = Project.create(owner_user_id="user_1", name="我的项目") + assert project.id # 自动生成 ID + assert len(project.id) == 32 # uuid4 hex + assert project.owner_user_id == "user_1" + assert project.name == "我的项目" + assert project.description == "" + assert project.shared_users == [] + + def test_create_with_description(self): + from packages.domain.entities import Project + + project = Project.create(owner_user_id="u1", name="测试项目", description="这是描述") + assert project.name == "测试项目" + assert project.description == "这是描述" + + def test_create_strips_whitespace(self): + from packages.domain.entities import Project + + project = Project.create(owner_user_id="u1", name=" 我的项目 ", description=" 描述 ") + assert project.name == "我的项目" + assert project.description == "描述" + + def test_create_empty_name_raises(self): + from packages.domain.entities import Project + + with pytest.raises(ValueError, match="项目名称不能为空"): + Project.create(owner_user_id="u1", name="") + + def test_create_whitespace_name_raises(self): + from packages.domain.entities import Project + + with pytest.raises(ValueError, match="项目名称不能为空"): + Project.create(owner_user_id="u1", name=" ") + + def test_is_owner_true(self): + from packages.domain.entities import Project + + project = Project.create(owner_user_id="user_1", name="P1") + assert project.is_owner("user_1") is True + + def test_is_owner_false(self): + from packages.domain.entities import Project + + project = Project.create(owner_user_id="user_1", name="P1") + assert project.is_owner("user_2") is False + + def test_is_shared_with_true(self): + from packages.domain.entities import Project + + project = Project(id="p1", owner_user_id="owner", name="P1", shared_users=["u1", "u2"]) + assert project.is_shared_with("u1") is True + assert project.is_shared_with("u2") is True + + def test_is_shared_with_false(self): + from packages.domain.entities import Project + + project = Project(id="p1", owner_user_id="owner", name="P1", shared_users=["u1"]) + assert project.is_shared_with("u3") is False + + def test_can_access_owner(self): + from packages.domain.entities import Project + + project = Project.create(owner_user_id="owner", name="P1") + assert project.can_access("owner") is True + + def test_can_access_shared_user(self): + from packages.domain.entities import Project + + project = Project(id="p1", owner_user_id="owner", name="P1", shared_users=["shared_user"]) + assert project.can_access("shared_user") is True + + def test_cannot_access_other(self): + from packages.domain.entities import Project + + project = Project.create(owner_user_id="owner", name="P1") + assert project.can_access("stranger") is False + + def test_has_created_at(self): + from packages.domain.entities import Project + + project = Project.create(owner_user_id="u1", name="P1") + assert isinstance(project.created_at, datetime) + + +class TestAssetLibrary: + """AssetLibrary 数据类测试""" + + def test_create_library(self): + from packages.domain.entities import AssetLibrary, AssetLibraryKind + + lib = AssetLibrary.create(project_id="proj_1", name="视频素材库", kind=AssetLibraryKind.VIDEO) + assert lib.id + assert len(lib.id) == 32 + assert lib.project_id == "proj_1" + assert lib.name == "视频素材库" + assert lib.kind == AssetLibraryKind.VIDEO + assert lib.asset_count == 0 + assert lib.total_size == 0 + + def test_create_strips_name(self): + from packages.domain.entities import AssetLibrary, AssetLibraryKind + + lib = AssetLibrary.create(project_id="p1", name=" 语音库 ", kind=AssetLibraryKind.VOICE) + assert lib.name == "语音库" + + def test_create_empty_name_raises(self): + from packages.domain.entities import AssetLibrary, AssetLibraryKind + + with pytest.raises(ValueError, match="素材库名称不能为空"): + AssetLibrary.create(project_id="p1", name="", kind=AssetLibraryKind.IMAGE) + + def test_image_library_kind(self): + from packages.domain.entities import AssetLibrary, AssetLibraryKind + + lib = AssetLibrary.create(project_id="p1", name="图片库", kind=AssetLibraryKind.IMAGE) + assert lib.kind == AssetLibraryKind.IMAGE + assert lib.kind == "image" + + def test_default_timestamps(self): + from packages.domain.entities import AssetLibrary, AssetLibraryKind + + lib = AssetLibrary.create(project_id="p1", name="L1", kind=AssetLibraryKind.VIDEO) + assert isinstance(lib.created_at, datetime) + assert isinstance(lib.updated_at, datetime) + + +class TestAsset: + """Asset 数据类 + 业务方法测试""" + + def test_create_minimal_asset(self): + from packages.domain.entities import Asset, AssetStatus, ClassificationStatus + + asset = Asset.create( + project_id="p1", + library_id="lib1", + name="test.mp4", + storage_key="videos/test.mp4", + mime_type="video/mp4", + ) + assert asset.id + assert len(asset.id) == 32 + assert asset.project_id == "p1" + assert asset.library_id == "lib1" + assert asset.name == "test.mp4" + assert asset.storage_key == "videos/test.mp4" + assert asset.mime_type == "video/mp4" + assert asset.status == AssetStatus.UPLOADING + assert asset.classification_status == ClassificationStatus.PENDING + assert asset.file_size == 0 + assert asset.metadata == {} + assert asset.tag_ids == [] + + def test_create_with_full_params(self): + from packages.domain.entities import Asset, AssetStatus, ClassificationStatus + + asset = Asset.create( + project_id="p1", + library_id="lib1", + name=" clip.mp4 ", + storage_key=" path/clip.mp4 ", + mime_type=" video/mp4 ", + metadata={"quality": "high"}, + file_size=1024000, + thumbnail_url="https://example.com/thumb.jpg", + duration=30.5, + width=1920, + height=1080, + fps=30.0, + codec="h264", + status=AssetStatus.READY, + classification_status=ClassificationStatus.COMPLETED, + quality_score=0.95, + uploaded_by_user_id=" user_1 ", + file_hash=" abc123 ", + ) + assert asset.name == "clip.mp4" # strip + assert asset.storage_key == "path/clip.mp4" + assert asset.mime_type == "video/mp4" + assert asset.file_size == 1024000 + assert asset.thumbnail_url == "https://example.com/thumb.jpg" + assert asset.duration == 30.5 + assert asset.width == 1920 + assert asset.height == 1080 + assert asset.fps == 30.0 + assert asset.codec == "h264" + assert asset.status == AssetStatus.READY + assert asset.classification_status == ClassificationStatus.COMPLETED + assert asset.quality_score == 0.95 + assert asset.uploaded_by_user_id == "user_1" + assert asset.file_hash == "abc123" + assert asset.metadata == {"quality": "high"} + + def test_create_empty_name_raises(self): + from packages.domain.entities import Asset + + with pytest.raises(ValueError, match="素材名称不能为空"): + Asset.create(project_id="p1", library_id="l1", name="", storage_key="k", mime_type="video/mp4") + + def test_create_empty_storage_key_raises(self): + from packages.domain.entities import Asset + + with pytest.raises(ValueError, match="storage_key 不能为空"): + Asset.create(project_id="p1", library_id="l1", name="a.mp4", storage_key=" ", mime_type="video/mp4") + + def test_create_empty_mime_type_raises(self): + from packages.domain.entities import Asset + + with pytest.raises(ValueError, match="mime_type 不能为空"): + Asset.create(project_id="p1", library_id="l1", name="a.mp4", storage_key="k", mime_type="") + + def test_file_type_video(self): + from packages.domain.entities import Asset + + asset = Asset.create(project_id="p1", library_id="l1", name="v.mp4", storage_key="k", mime_type="video/mp4") + assert asset.file_type == "video" + + def test_file_type_audio(self): + from packages.domain.entities import Asset + + asset = Asset.create(project_id="p1", library_id="l1", name="a.mp3", storage_key="k", mime_type="audio/mpeg") + assert asset.file_type == "audio" + + def test_file_type_image(self): + from packages.domain.entities import Asset + + asset = Asset.create(project_id="p1", library_id="l1", name="i.jpg", storage_key="k", mime_type="image/jpeg") + assert asset.file_type == "image" + + def test_file_type_no_slash(self): + from packages.domain.entities import Asset + + asset = Asset.create(project_id="p1", library_id="l1", name="f.bin", storage_key="k", mime_type="octet-stream") + assert asset.file_type == "octet-stream" + + def test_add_tag(self): + from packages.domain.entities import Asset + + asset = Asset.create(project_id="p1", library_id="l1", name="v.mp4", storage_key="k", mime_type="video/mp4") + asset.add_tag("tag_1") + assert "tag_1" in asset.tag_ids + assert len(asset.tag_ids) == 1 + + def test_add_tag_strips_whitespace(self): + from packages.domain.entities import Asset + + asset = Asset.create(project_id="p1", library_id="l1", name="v.mp4", storage_key="k", mime_type="video/mp4") + asset.add_tag(" tag_1 ") + assert asset.tag_ids == ["tag_1"] + + def test_add_tag_deduplicates(self): + from packages.domain.entities import Asset + + asset = Asset.create(project_id="p1", library_id="l1", name="v.mp4", storage_key="k", mime_type="video/mp4") + asset.add_tag("tag_1") + asset.add_tag("tag_1") + assert asset.tag_ids.count("tag_1") == 1 + + def test_add_empty_tag_raises(self): + from packages.domain.entities import Asset + + asset = Asset.create(project_id="p1", library_id="l1", name="v.mp4", storage_key="k", mime_type="video/mp4") + with pytest.raises(ValueError, match="标签 ID 不能为空"): + asset.add_tag(" ") + + def test_add_tag_updates_updated_at(self): + from time import sleep + + from packages.domain.entities import Asset + + asset = Asset.create(project_id="p1", library_id="l1", name="v.mp4", storage_key="k", mime_type="video/mp4") + original = asset.updated_at + sleep(0.001) + asset.add_tag("tag_1") + assert asset.updated_at > original + + def test_remove_tag_existing(self): + from packages.domain.entities import Asset + + asset = Asset.create(project_id="p1", library_id="l1", name="v.mp4", storage_key="k", mime_type="video/mp4") + asset.add_tag("tag_1") + asset.add_tag("tag_2") + asset.remove_tag("tag_1") + assert "tag_1" not in asset.tag_ids + assert "tag_2" in asset.tag_ids + assert len(asset.tag_ids) == 1 + + def test_remove_tag_nonexistent_is_idempotent(self): + """删除不存在的标签不报错""" + from packages.domain.entities import Asset + + asset = Asset.create(project_id="p1", library_id="l1", name="v.mp4", storage_key="k", mime_type="video/mp4") + asset.remove_tag("nonexistent") # 不抛异常 + assert asset.tag_ids == [] + + def test_remove_tag_strips_whitespace(self): + from packages.domain.entities import Asset + + asset = Asset.create(project_id="p1", library_id="l1", name="v.mp4", storage_key="k", mime_type="video/mp4") + asset.add_tag("tag_1") + asset.remove_tag(" tag_1 ") + assert "tag_1" not in asset.tag_ids + + def test_remove_tag_updates_updated_at(self): + from time import sleep + + from packages.domain.entities import Asset + + asset = Asset.create(project_id="p1", library_id="l1", name="v.mp4", storage_key="k", mime_type="video/mp4") + asset.add_tag("tag_1") + original = asset.updated_at + sleep(0.001) + asset.remove_tag("tag_1") + assert asset.updated_at > original + + def test_thumbnail_none_when_falsy(self): + """空字符串 thumbnail 存为 None""" + from packages.domain.entities import Asset + + asset = Asset.create( + project_id="p1", + library_id="l1", + name="v.mp4", + storage_key="k", + mime_type="video/mp4", + thumbnail_url="", + ) + assert asset.thumbnail_url is None + + def test_metadata_default_empty_dict(self): + from packages.domain.entities import Asset + + asset = Asset.create( + project_id="p1", + library_id="l1", + name="v.mp4", + storage_key="k", + mime_type="video/mp4", + metadata=None, + ) + assert asset.metadata == {} + # 不共享同一个默认 dict + asset2 = Asset.create( + project_id="p1", + library_id="l1", + name="v2.mp4", + storage_key="k2", + mime_type="video/mp4", + ) + asset.metadata["test"] = "value" + assert "test" not in asset2.metadata + + +class TestIngestJob: + """IngestJob 数据类测试""" + + def test_create_minimal(self): + from packages.domain.entities import IngestJob, IngestJobStatus + + job = IngestJob.create( + project_id="p1", + library_id="lib1", + storage_key="videos/test.mp4", + ) + assert job.id + assert len(job.id) == 32 + assert job.project_id == "p1" + assert job.library_id == "lib1" + assert job.storage_key == "videos/test.mp4" + assert job.status == IngestJobStatus.PENDING + assert job.error_message == "" + assert job.result_asset_id == "" + assert job.file_hash == "" + + def test_create_with_file_hash(self): + from packages.domain.entities import IngestJob + + job = IngestJob.create( + project_id="p1", + library_id="lib1", + storage_key="k", + file_hash="abc123def456", + ) + assert job.file_hash == "abc123def456" + + def test_create_strips_fields(self): + from packages.domain.entities import IngestJob + + job = IngestJob.create( + project_id=" p1 ", + library_id=" lib1 ", + storage_key=" key ", + file_hash=" hash ", + ) + assert job.project_id == "p1" + assert job.library_id == "lib1" + assert job.storage_key == "key" + assert job.file_hash == "hash" + + def test_create_empty_project_id_raises(self): + from packages.domain.entities import IngestJob + + with pytest.raises(ValueError, match="project_id 不能为空"): + IngestJob.create(project_id="", library_id="l1", storage_key="k") + + def test_create_empty_library_id_raises(self): + from packages.domain.entities import IngestJob + + with pytest.raises(ValueError, match="library_id 不能为空"): + IngestJob.create(project_id="p1", library_id=" ", storage_key="k") + + def test_create_empty_storage_key_raises(self): + from packages.domain.entities import IngestJob + + with pytest.raises(ValueError, match="storage_key 不能为空"): + IngestJob.create(project_id="p1", library_id="l1", storage_key="") + + def test_failed_status(self): + from packages.domain.entities import IngestJob, IngestJobStatus + + job = IngestJob( + id="j1", + project_id="p1", + library_id="l1", + storage_key="k", + status=IngestJobStatus.FAILED, + error_message="转码失败", + ) + assert job.status == IngestJobStatus.FAILED + assert job.error_message == "转码失败" + + def test_completed_with_result(self): + from packages.domain.entities import IngestJob, IngestJobStatus + + job = IngestJob( + id="j1", + project_id="p1", + library_id="l1", + storage_key="k", + status=IngestJobStatus.COMPLETED, + result_asset_id="asset_123", + ) + assert job.status == IngestJobStatus.COMPLETED + assert job.result_asset_id == "asset_123" + + def test_has_timestamps(self): + from packages.domain.entities import IngestJob + + job = IngestJob.create(project_id="p1", library_id="l1", storage_key="k") + assert isinstance(job.created_at, datetime) + assert isinstance(job.updated_at, datetime)