Files
2026-moving-helper/tests/test_app.py
T

1013 lines
31 KiB
Python
Raw Normal View History

2026-04-19 12:54:25 +02:00
from io import BytesIO
2026-04-19 12:13:07 +02:00
from pathlib import Path
2026-04-19 12:36:55 +02:00
import app.db as db_module
2026-04-19 14:38:23 +02:00
import app.images as images_module
2026-04-19 12:54:25 +02:00
from PIL import Image
2026-04-19 14:38:23 +02:00
from fastapi import UploadFile
2026-04-19 12:54:25 +02:00
2026-04-19 12:36:55 +02:00
from app.models import Box, Item, SubItem
2026-04-19 12:13:07 +02:00
2026-04-19 12:54:25 +02:00
def create_box(client, name="Box A", note="Packed", room="Bedroom", status="ready", image=None):
data = {"name": name, "note": note, "room": room, "status": status}
files = {"image_file": image} if image is not None else None
return client.post("/boxes", data=data, files=files, follow_redirects=False)
2026-04-19 12:36:55 +02:00
2026-04-19 12:54:25 +02:00
def create_item(
client,
box_id,
name="Item A",
note="Note",
quantity="2",
is_container=False,
image=None,
2026-04-19 13:31:17 +02:00
submit_action="save",
2026-04-19 12:54:25 +02:00
):
2026-04-19 13:31:17 +02:00
data = {"name": name, "note": note, "quantity": quantity, "submit_action": submit_action}
2026-04-19 12:36:55 +02:00
if is_container:
data["is_container"] = "on"
2026-04-19 12:54:25 +02:00
files = {"image_file": image} if image is not None else None
return client.post(f"/boxes/{box_id}/items", data=data, files=files, follow_redirects=False)
2026-04-19 12:36:55 +02:00
2026-04-19 13:31:17 +02:00
def create_subitem(
client,
item_id,
name="SubItem A",
note="Small",
quantity="3",
image=None,
submit_action="save",
):
2026-04-19 12:54:25 +02:00
files = {"image_file": image} if image is not None else None
2026-04-19 12:36:55 +02:00
return client.post(
f"/items/{item_id}/subitems",
2026-04-19 13:31:17 +02:00
data={"name": name, "note": note, "quantity": quantity, "submit_action": submit_action},
2026-04-19 12:54:25 +02:00
files=files,
2026-04-19 12:36:55 +02:00
follow_redirects=False,
)
2026-04-19 12:13:07 +02:00
2026-04-19 12:36:55 +02:00
2026-04-19 12:54:25 +02:00
def make_image_upload(
filename="sample.png",
image_format="PNG",
size=(2000, 1000),
color=(255, 0, 0),
mode="RGB",
):
image = Image.new(mode, size, color)
output = BytesIO()
image.save(output, format=image_format)
return (filename, output.getvalue(), "image/png")
2026-04-19 14:47:18 +02:00
def make_oriented_jpeg_upload(
filename="portrait.jpg",
size=(1600, 900),
orientation=6,
):
image = Image.new("RGB", size, (20, 120, 220))
exif = Image.Exif()
exif[274] = orientation
output = BytesIO()
image.save(output, format="JPEG", exif=exif)
return (filename, output.getvalue(), "image/jpeg")
2026-04-19 12:54:25 +02:00
def read_jpeg_size(image_bytes):
with Image.open(BytesIO(image_bytes)) as image:
return image.format, image.size
2026-04-19 12:36:55 +02:00
def test_app_uses_isolated_sqlite_database(client):
assert db_module.engine is not None
database_name = Path(db_module.engine.url.database)
assert database_name.name == "test.db"
assert "data/app.db" not in str(database_name)
2026-04-19 12:13:07 +02:00
def test_root_redirects_to_boxes(client):
response = client.get("/", follow_redirects=False)
assert response.status_code == 302
assert response.headers["location"] == "/boxes"
def test_boxes_page_returns_200(client):
response = client.get("/boxes")
assert response.status_code == 200
2026-04-19 12:36:55 +02:00
assert "箱子" in response.text
2026-04-19 12:13:07 +02:00
def test_boxes_page_uses_relative_stylesheet_path(client):
response = client.get("/boxes")
assert response.status_code == 200
assert 'href="/static/style.css"' in response.text
assert "http://" not in response.text.split("/static/style.css")[0]
def test_boxes_overview_card_shows_note_and_item_count_without_room_or_status(client, db_session):
box = Box(
name="Kitchen Box",
note="易碎餐具和杯子",
room="Kitchen",
status="packed",
)
box.items.append(Item(name="Plate", is_container=False))
db_session.add(box)
db_session.commit()
response = client.get("/boxes")
assert response.status_code == 200
assert "Kitchen Box" in response.text
assert "物品数:1" in response.text
assert "易碎餐具和杯子" in response.text
assert "房间:" not in response.text
assert "状态:" not in response.text
def test_boxes_overview_renders_cleanly_when_note_is_empty(client, db_session):
box = Box(name="No Note Box", note=None, room="Office", status="open")
db_session.add(box)
db_session.commit()
response = client.get("/boxes")
assert response.status_code == 200
assert "No Note Box" in response.text
assert "房间:" not in response.text
assert "状态:" not in response.text
2026-04-19 12:36:55 +02:00
def test_can_create_box(client, db_session):
response = create_box(client, name="Kitchen Box")
2026-04-19 12:13:07 +02:00
2026-04-19 12:36:55 +02:00
assert response.status_code == 303
box = db_session.query(Box).one()
assert box.name == "Kitchen Box"
assert box.room == "Bedroom"
def test_can_edit_box(client, db_session):
box = Box(name="Old Box")
db_session.add(box)
db_session.commit()
response = client.post(
f"/boxes/{box.id}/update",
data={"name": "Updated Box", "note": "Updated", "room": "Office", "status": "open"},
follow_redirects=False,
)
assert response.status_code == 303
db_session.refresh(box)
assert box.name == "Updated Box"
assert box.room == "Office"
assert box.status == "open"
def test_can_delete_box(client, db_session):
box = Box(name="Delete Me")
db_session.add(box)
db_session.commit()
box_id = box.id
response = client.post(f"/boxes/{box_id}/delete", follow_redirects=False)
assert response.status_code == 303
db_session.expire_all()
assert db_session.get(Box, box_id) is None
def test_deleting_box_cascades_to_items_and_subitems(client, db_session):
box = Box(name="Cascade Box")
item = Item(name="Container", is_container=True, box=box)
subitem = SubItem(name="Cable", parent_item=item)
db_session.add_all([box, item, subitem])
db_session.commit()
response = client.post(f"/boxes/{box.id}/delete", follow_redirects=False)
assert response.status_code == 303
assert db_session.query(Box).count() == 0
assert db_session.query(Item).count() == 0
assert db_session.query(SubItem).count() == 0
def test_missing_box_returns_404(client):
response = client.get("/boxes/9999")
assert response.status_code == 404
def test_box_detail_returns_200_when_box_exists(client, db_session):
box = Box(name="Visible Box")
db_session.add(box)
db_session.commit()
response = client.get(f"/boxes/{box.id}")
assert response.status_code == 200
assert "Visible Box" in response.text
def test_box_detail_item_cards_show_notes_without_note_placeholder_text(client, db_session):
box = Box(name="Overview Box")
box.items.append(Item(name="Accessory Pouch", note="充电器和转换头", is_container=True, quantity=2))
db_session.add(box)
db_session.commit()
response = client.get(f"/boxes/{box.id}")
assert response.status_code == 200
assert "Accessory Pouch" in response.text
assert "数量:2" in response.text
assert "充电器和转换头" in response.text
assert "有备注" not in response.text
2026-04-19 12:36:55 +02:00
def test_can_create_regular_item_under_box(client, db_session):
box = Box(name="Main Box")
db_session.add(box)
db_session.commit()
response = create_item(client, box.id, name="Book", is_container=False)
assert response.status_code == 303
item = db_session.query(Item).one()
assert item.name == "Book"
assert item.is_container is False
def test_can_create_container_item_under_box(client, db_session):
box = Box(name="Main Box")
db_session.add(box)
db_session.commit()
response = create_item(client, box.id, name="Accessory Pouch", is_container=True)
assert response.status_code == 303
item = db_session.query(Item).one()
assert item.is_container is True
def test_can_edit_item(client, db_session):
box = Box(name="Main Box")
item = Item(name="Old Item", box=box, is_container=False)
db_session.add_all([box, item])
db_session.commit()
response = client.post(
f"/items/{item.id}/update",
data={"name": "New Item", "note": "Changed", "quantity": "7", "is_container": "on"},
follow_redirects=False,
)
assert response.status_code == 303
db_session.refresh(item)
assert item.name == "New Item"
assert item.quantity == 7
assert item.is_container is True
def test_can_delete_item(client, db_session):
box = Box(name="Main Box")
item = Item(name="Delete Item", box=box, is_container=False)
db_session.add_all([box, item])
db_session.commit()
item_id = item.id
response = client.post(f"/items/{item_id}/delete", follow_redirects=False)
assert response.status_code == 303
db_session.expire_all()
assert db_session.get(Item, item_id) is None
def test_deleting_container_item_cascades_to_subitems(client, db_session):
box = Box(name="Main Box")
item = Item(name="Container", box=box, is_container=True)
subitem = SubItem(name="Clip", parent_item=item)
db_session.add_all([box, item, subitem])
db_session.commit()
response = client.post(f"/items/{item.id}/delete", follow_redirects=False)
assert response.status_code == 303
assert db_session.query(Item).count() == 0
assert db_session.query(SubItem).count() == 0
def test_missing_item_returns_404(client):
response = client.get("/items/9999")
assert response.status_code == 404
def test_item_detail_returns_200_when_item_exists(client, db_session):
box = Box(name="Main Box")
item = Item(name="Visible Item", box=box, is_container=False)
db_session.add_all([box, item])
db_session.commit()
response = client.get(f"/items/{item.id}")
assert response.status_code == 200
assert "Visible Item" in response.text
def test_can_create_subitem_under_container_item(client, db_session):
box = Box(name="Main Box")
item = Item(name="Pouch", box=box, is_container=True)
db_session.add_all([box, item])
db_session.commit()
response = create_subitem(client, item.id, name="USB Cable")
assert response.status_code == 303
subitem = db_session.query(SubItem).one()
assert subitem.name == "USB Cable"
def test_cannot_create_subitem_under_non_container_item(client, db_session):
box = Box(name="Main Box")
item = Item(name="Book", box=box, is_container=False)
db_session.add_all([box, item])
db_session.commit()
response = create_subitem(client, item.id, name="Should Fail")
assert response.status_code == 400
assert response.json()["detail"] == "Only container items can have sub-items"
assert db_session.query(SubItem).count() == 0
def test_can_edit_subitem(client, db_session):
box = Box(name="Main Box")
item = Item(name="Pouch", box=box, is_container=True)
subitem = SubItem(name="Old Sub-item", parent_item=item)
db_session.add_all([box, item, subitem])
db_session.commit()
response = client.post(
f"/subitems/{subitem.id}/update",
data={"name": "New Sub-item", "note": "Updated", "quantity": "9"},
follow_redirects=False,
)
assert response.status_code == 303
db_session.refresh(subitem)
assert subitem.name == "New Sub-item"
assert subitem.quantity == 9
def test_can_delete_subitem(client, db_session):
box = Box(name="Main Box")
item = Item(name="Pouch", box=box, is_container=True)
subitem = SubItem(name="Delete Sub-item", parent_item=item)
db_session.add_all([box, item, subitem])
db_session.commit()
subitem_id = subitem.id
response = client.post(f"/subitems/{subitem_id}/delete", follow_redirects=False)
assert response.status_code == 303
db_session.expire_all()
assert db_session.get(SubItem, subitem_id) is None
def test_missing_subitem_returns_404(client):
response = client.get("/subitems/9999/edit")
assert response.status_code == 404
def test_post_redirects_are_reasonable(client, db_session):
box = Box(name="Redirect Box")
db_session.add(box)
db_session.commit()
item_response = create_item(client, box.id, name="Lamp")
2026-04-19 13:31:17 +02:00
item = db_session.query(Item).one()
2026-04-19 12:36:55 +02:00
item.is_container = True
db_session.commit()
subitem_response = create_subitem(client, item.id, name="Bulb")
2026-04-19 13:31:17 +02:00
assert item_response.headers["location"] == f"/boxes/{box.id}"
2026-04-19 12:36:55 +02:00
assert subitem_response.headers["location"] == f"/items/{item.id}"
2026-04-19 12:54:25 +02:00
def test_can_upload_image_for_box_and_process_it(client, db_session):
response = create_box(client, name="Photo Box", image=make_image_upload())
assert response.status_code == 303
box = db_session.query(Box).one()
assert box.image_blob is not None
assert box.image_mime_type == "image/jpeg"
assert box.image_width == 1600
assert box.image_height == 800
image_format, image_size = read_jpeg_size(box.image_blob)
assert image_format == "JPEG"
assert image_size == (1600, 800)
2026-04-19 14:47:18 +02:00
def test_image_pipeline_applies_exif_orientation_before_saving(client, db_session):
response = create_box(client, name="Portrait Box", image=make_oriented_jpeg_upload())
assert response.status_code == 303
box = db_session.query(Box).filter_by(name="Portrait Box").one()
assert box.image_blob is not None
assert box.image_width == 900
assert box.image_height == 1600
image_format, image_size = read_jpeg_size(box.image_blob)
assert image_format == "JPEG"
assert image_size == (900, 1600)
2026-04-19 12:54:25 +02:00
def test_can_upload_image_for_item(client, db_session):
box = Box(name="Main Box")
db_session.add(box)
db_session.commit()
response = create_item(client, box.id, name="Book", image=make_image_upload())
assert response.status_code == 303
item = db_session.query(Item).one()
assert item.image_blob is not None
assert item.image_mime_type == "image/jpeg"
def test_can_upload_image_for_subitem(client, db_session):
box = Box(name="Main Box")
item = Item(name="Pouch", box=box, is_container=True)
db_session.add_all([box, item])
db_session.commit()
response = create_subitem(client, item.id, name="Cable", image=make_image_upload())
assert response.status_code == 303
subitem = db_session.query(SubItem).one()
assert subitem.image_blob is not None
assert subitem.image_mime_type == "image/jpeg"
def test_box_image_route_returns_jpeg(client, db_session):
box = Box(name="Photo Box")
db_session.add(box)
db_session.commit()
client.post(
f"/boxes/{box.id}/update",
data={"name": box.name, "note": "", "room": "", "status": ""},
files={"image_file": make_image_upload()},
follow_redirects=False,
)
db_session.expire_all()
response = client.get(f"/boxes/{box.id}/image")
assert response.status_code == 200
assert response.headers["content-type"] == "image/jpeg"
def test_item_image_route_returns_jpeg(client, db_session):
box = Box(name="Main Box")
item = Item(name="Lamp", box=box, is_container=False)
db_session.add_all([box, item])
db_session.commit()
client.post(
f"/items/{item.id}/update",
data={"name": item.name, "note": "", "quantity": "", "is_container": ""},
files={"image_file": make_image_upload()},
follow_redirects=False,
)
response = client.get(f"/items/{item.id}/image")
assert response.status_code == 200
assert response.headers["content-type"] == "image/jpeg"
def test_subitem_image_route_returns_jpeg(client, db_session):
box = Box(name="Main Box")
item = Item(name="Pouch", box=box, is_container=True)
subitem = SubItem(name="Cable", parent_item=item)
db_session.add_all([box, item, subitem])
db_session.commit()
client.post(
f"/subitems/{subitem.id}/update",
data={"name": subitem.name, "note": "", "quantity": ""},
files={"image_file": make_image_upload()},
follow_redirects=False,
)
response = client.get(f"/subitems/{subitem.id}/image")
assert response.status_code == 200
assert response.headers["content-type"] == "image/jpeg"
def test_image_routes_return_404_when_no_image(client, db_session):
box = Box(name="Main Box")
item = Item(name="Lamp", box=box, is_container=False)
subitem = SubItem(name="Cable", parent_item=Item(name="Pouch", box=box, is_container=True))
db_session.add_all([box, item, subitem.parent_item, subitem])
db_session.commit()
assert client.get(f"/boxes/{box.id}/image").status_code == 404
assert client.get(f"/items/{item.id}/image").status_code == 404
assert client.get(f"/subitems/{subitem.id}/image").status_code == 404
def test_image_routes_return_404_for_missing_objects(client):
assert client.get("/boxes/9999/image").status_code == 404
assert client.get("/items/9999/image").status_code == 404
assert client.get("/subitems/9999/image").status_code == 404
def test_can_replace_existing_image(client, db_session):
box = Box(name="Photo Box")
db_session.add(box)
db_session.commit()
client.post(
f"/boxes/{box.id}/update",
data={"name": box.name, "note": "", "room": "", "status": ""},
files={"image_file": make_image_upload(color=(255, 0, 0))},
follow_redirects=False,
)
db_session.expire_all()
first_blob = db_session.get(Box, box.id).image_blob
client.post(
f"/boxes/{box.id}/update",
data={"name": box.name, "note": "", "room": "", "status": ""},
files={"image_file": make_image_upload(color=(0, 255, 0))},
follow_redirects=False,
)
db_session.expire_all()
updated_box = db_session.get(Box, box.id)
assert updated_box.image_blob is not None
assert updated_box.image_blob != first_blob
def test_can_delete_existing_image_and_clear_metadata(client, db_session):
box = Box(name="Photo Box")
db_session.add(box)
db_session.commit()
client.post(
f"/boxes/{box.id}/update",
data={"name": box.name, "note": "", "room": "", "status": ""},
files={"image_file": make_image_upload()},
follow_redirects=False,
)
response = client.post(f"/boxes/{box.id}/image/delete", follow_redirects=False)
assert response.status_code == 303
db_session.expire_all()
updated_box = db_session.get(Box, box.id)
assert updated_box.image_blob is None
assert updated_box.image_mime_type is None
assert updated_box.image_width is None
assert updated_box.image_height is None
assert client.get(f"/boxes/{box.id}/image").status_code == 404
def test_deleting_image_when_none_exists_is_safe(client, db_session):
item = Item(name="No Image Item", box=Box(name="Main Box"), is_container=False)
db_session.add(item)
db_session.commit()
response = client.post(f"/items/{item.id}/image/delete", follow_redirects=False)
assert response.status_code == 303
def test_uploading_non_image_file_returns_400_and_does_not_write_data(client, db_session):
box = Box(name="Main Box")
db_session.add(box)
db_session.commit()
response = client.post(
f"/items/{box.id}/subitems",
data={"name": "Should Fail", "note": "", "quantity": ""},
files={"image_file": ("bad.txt", b"not an image", "text/plain")},
follow_redirects=False,
)
assert response.status_code in {400, 404}
item = Item(name="Pouch", box=box, is_container=True)
db_session.add(item)
db_session.commit()
response = create_subitem(
client,
item.id,
name="Should Fail",
image=("bad.txt", b"not an image", "text/plain"),
)
assert response.status_code == 400
assert response.json()["detail"] == "上传的文件不是合法图片"
assert db_session.query(SubItem).count() == 0
def test_broken_image_processing_returns_400_and_keeps_image_fields_empty(client, db_session):
box = Box(name="Main Box")
db_session.add(box)
db_session.commit()
response = client.post(
f"/boxes/{box.id}/update",
data={"name": box.name, "note": "", "room": "", "status": ""},
files={"image_file": ("broken.jpg", b"\xff\xd8\xff\xdbbroken", "image/jpeg")},
follow_redirects=False,
)
assert response.status_code == 400
db_session.expire_all()
updated_box = db_session.get(Box, box.id)
assert updated_box.image_blob is None
assert updated_box.image_mime_type is None
assert updated_box.image_width is None
assert updated_box.image_height is None
2026-04-19 13:00:11 +02:00
2026-04-19 14:38:23 +02:00
def test_heic_upload_returns_clear_error_if_heif_support_is_unavailable(monkeypatch):
heic_file = UploadFile(filename="sample.heic", file=BytesIO(b"not-a-real-heic"))
def fake_open(*args, **kwargs):
raise images_module.UnidentifiedImageError("cannot identify image file")
monkeypatch.setattr(images_module, "HEIF_SUPPORT_ENABLED", False)
monkeypatch.setattr(images_module.Image, "open", fake_open)
monkeypatch.setattr(images_module.shutil, "which", lambda command: None)
try:
images_module.process_upload(heic_file)
assert False, "Expected HEIC upload to raise HTTPException"
except Exception as exc:
assert getattr(exc, "status_code", None) == 400
assert "HEIC/HEIF" in getattr(exc, "detail", "")
2026-04-19 13:00:11 +02:00
def test_can_search_box_by_name(client, db_session):
box = Box(name="冬季衣物箱")
db_session.add(box)
db_session.commit()
response = client.get("/search?q=衣物")
assert response.status_code == 200
assert "冬季衣物箱" in response.text
assert "类型:Box" in response.text
def test_can_search_box_by_note(client, db_session):
box = Box(name="普通箱子", note="里面放厨房锅具")
db_session.add(box)
db_session.commit()
response = client.get("/search?q=锅具")
assert response.status_code == 200
assert "普通箱子" in response.text
def test_can_search_item_by_name(client, db_session):
box = Box(name="书房箱")
item = Item(name="电源延长线", box=box, is_container=False)
db_session.add_all([box, item])
db_session.commit()
response = client.get("/search?q=延长线")
assert response.status_code == 200
assert "电源延长线" in response.text
assert "位于箱子:书房箱" in response.text
def test_can_search_item_by_note(client, db_session):
box = Box(name="工具箱")
item = Item(name="杂项", note="放备用螺丝刀", box=box, is_container=False)
db_session.add_all([box, item])
db_session.commit()
response = client.get("/search?q=螺丝刀")
assert response.status_code == 200
assert "杂项" in response.text
def test_can_search_subitem_by_name(client, db_session):
box = Box(name="文件箱")
item = Item(name="文件袋", box=box, is_container=True)
subitem = SubItem(name="护照复印件", parent_item=item)
db_session.add_all([box, item, subitem])
db_session.commit()
response = client.get("/search?q=护照")
assert response.status_code == 200
assert "护照复印件" in response.text
assert "位于物品:文件袋 / 箱子:文件箱" in response.text
def test_can_search_subitem_by_note(client, db_session):
box = Box(name="电子箱")
item = Item(name="配件盒", box=box, is_container=True)
subitem = SubItem(name="接口", note="备用转接头", parent_item=item)
db_session.add_all([box, item, subitem])
db_session.commit()
response = client.get("/search?q=转接头")
assert response.status_code == 200
assert "接口" in response.text
def test_search_result_shows_item_box_info(client, db_session):
box = Box(name="客厅箱")
item = Item(name="相机", box=box, is_container=True)
db_session.add_all([box, item])
db_session.commit()
response = client.get("/search?q=相机")
assert response.status_code == 200
assert "位于箱子:客厅箱" in response.text
assert "容器" in response.text
assert "" in response.text
def test_search_result_shows_subitem_item_and_box_info(client, db_session):
box = Box(name="卧室箱")
item = Item(name="收纳袋", box=box, is_container=True)
subitem = SubItem(name="袜子", parent_item=item)
db_session.add_all([box, item, subitem])
db_session.commit()
response = client.get("/search?q=袜子")
assert response.status_code == 200
assert "位于物品:收纳袋 / 箱子:卧室箱" in response.text
def test_search_page_handles_empty_query(client):
response = client.get("/search")
assert response.status_code == 200
assert "输入关键词后,可以跨 Box、Item、SubItem 进行搜索。" in response.text
def test_search_page_handles_no_results(client):
response = client.get("/search?q=不存在的关键词")
assert response.status_code == 200
assert "没有找到匹配结果。" in response.text
def test_search_result_renders_thumbnail_link_when_image_exists(client, db_session):
box = Box(name="图片箱")
db_session.add(box)
db_session.commit()
client.post(
f"/boxes/{box.id}/update",
data={"name": box.name, "note": "", "room": "", "status": ""},
files={"image_file": make_image_upload()},
follow_redirects=False,
)
response = client.get("/search?q=图片箱")
assert response.status_code == 200
assert f'/boxes/{box.id}/image' in response.text
def test_search_result_without_image_does_not_break_template(client, db_session):
item = Item(name="无图物品", box=Box(name="普通箱"), is_container=False)
db_session.add(item)
db_session.commit()
response = client.get("/search?q=无图物品")
assert response.status_code == 200
assert "无图物品" in response.text
2026-04-19 13:26:23 +02:00
def test_new_box_page_shows_clear_context(client):
response = client.get("/boxes/new")
assert response.status_code == 200
assert "新建 Box" in response.text
assert "创建顶层箱子" in response.text
def test_new_item_page_shows_clear_context_and_default_quantity(client, db_session):
box = Box(name="主卧箱")
db_session.add(box)
db_session.commit()
response = client.get(f"/boxes/{box.id}/items/new")
assert response.status_code == 200
assert "新建 Item" in response.text
assert "主卧箱" in response.text
assert 'name="quantity"' in response.text
assert 'value="1"' in response.text
assert "这个物品本身是一个小容器" in response.text
2026-04-19 13:31:17 +02:00
assert "保存并添加下一个" in response.text
2026-04-19 13:26:23 +02:00
def test_new_subitem_page_shows_clear_context_and_default_quantity(client, db_session):
box = Box(name="客厅箱")
item = Item(name="文件袋", box=box, is_container=True)
db_session.add_all([box, item])
db_session.commit()
response = client.get(f"/items/{item.id}/subitems/new")
assert response.status_code == 200
assert "新建 SubItem" in response.text
assert "客厅箱" in response.text
assert "文件袋" in response.text
assert 'name="quantity"' in response.text
assert 'value="1"' in response.text
2026-04-19 13:31:17 +02:00
assert "保存并添加下一个" in response.text
2026-04-19 13:26:23 +02:00
def test_box_detail_page_renders_clear_hierarchy_and_dense_list_structure(client, db_session):
box = Box(name="厨房箱")
item = Item(name="", box=box, is_container=False)
db_session.add_all([box, item])
db_session.commit()
response = client.get(f"/boxes/{box.id}")
assert response.status_code == 200
assert "Box" in response.text
assert "厨房箱" in response.text
2026-04-19 14:06:31 +02:00
assert "overview-grid" in response.text
assert f'data-href="/items/{item.id}"' in response.text
assert "是否容器" not in response.text
2026-04-19 13:26:23 +02:00
def test_item_detail_page_renders_clear_hierarchy(client, db_session):
box = Box(name="书房箱")
item = Item(name="配件盒", box=box, is_container=True)
subitem = SubItem(name="转接头", parent_item=item)
db_session.add_all([box, item, subitem])
db_session.commit()
response = client.get(f"/items/{item.id}")
assert response.status_code == 200
assert "容器型 Item" in response.text
assert "书房箱" in response.text
assert "SubItem" in response.text
2026-04-19 14:06:31 +02:00
assert f'data-href="/subitems/{subitem.id}/edit"' in response.text
assert "overview-grid" in response.text
def test_box_detail_page_shows_primary_and_secondary_cta_buttons(client, db_session):
box = Box(name="操作箱")
db_session.add(box)
db_session.commit()
response = client.get(f"/boxes/{box.id}")
assert response.status_code == 200
assert "button button-primary" in response.text
assert "button button-secondary" in response.text
assert "删除箱子" in response.text
def test_boxes_overview_uses_clickable_cards_without_detail_edit_buttons(client, db_session):
box = Box(name="可点击箱子")
db_session.add(box)
db_session.commit()
response = client.get("/boxes")
assert response.status_code == 200
assert f'data-href="/boxes/{box.id}"' in response.text
assert "查看详情" not in response.text
assert "编辑</a>" not in response.text
def test_item_detail_page_shows_primary_action_for_adding_subitems(client, db_session):
box = Box(name="容器箱")
item = Item(name="收纳盒", box=box, is_container=True)
db_session.add_all([box, item])
db_session.commit()
response = client.get(f"/items/{item.id}")
assert response.status_code == 200
assert "添加子物品" in response.text
assert "button button-primary" in response.text
2026-04-19 13:31:17 +02:00
def test_creating_regular_item_redirects_back_to_parent_box(client, db_session):
box = Box(name="连续录入箱")
db_session.add(box)
db_session.commit()
response = create_item(client, box.id, name="剪刀", is_container=False)
assert response.status_code == 303
assert response.headers["location"] == f"/boxes/{box.id}"
def test_creating_regular_subitem_redirects_back_to_parent_item(client, db_session):
box = Box(name="配件箱")
item = Item(name="线材袋", box=box, is_container=True)
db_session.add_all([box, item])
db_session.commit()
response = create_subitem(client, item.id, name="USB头")
assert response.status_code == 303
assert response.headers["location"] == f"/items/{item.id}"
def test_creating_box_redirects_to_new_box_detail(client):
response = create_box(client, name="新箱子")
assert response.status_code == 303
assert response.headers["location"].startswith("/boxes/")
assert not response.headers["location"].endswith("/items/new")
def test_creating_container_item_redirects_to_item_detail(client, db_session):
box = Box(name="子容器箱")
db_session.add(box)
db_session.commit()
response = create_item(client, box.id, name="小收纳盒", is_container=True)
created_item = db_session.query(Item).one()
assert response.status_code == 303
assert response.headers["location"] == f"/items/{created_item.id}"
def test_creating_item_with_save_and_add_next_returns_to_same_new_item_context(client, db_session):
box = Box(name="快速录入箱")
db_session.add(box)
db_session.commit()
response = create_item(
client,
box.id,
name="袜子",
is_container=False,
submit_action="save_and_add_next",
)
assert response.status_code == 303
assert response.headers["location"] == f"/boxes/{box.id}/items/new"
def test_creating_subitem_with_save_and_add_next_returns_to_same_new_subitem_context(client, db_session):
box = Box(name="电子箱")
item = Item(name="配件袋", box=box, is_container=True)
db_session.add_all([box, item])
db_session.commit()
response = create_subitem(
client,
item.id,
name="转接头",
submit_action="save_and_add_next",
)
assert response.status_code == 303
assert response.headers["location"] == f"/items/{item.id}/subitems/new"