Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| facf82c898 | |||
| d24c41d05f | |||
| 4955c87d78 | |||
| bfa554b407 | |||
| e5fee32098 | |||
| 22ea44d8cd | |||
| ed1e3311a5 |
@@ -14,3 +14,4 @@ data/*.db
|
|||||||
**/.Spotlight-V100
|
**/.Spotlight-V100
|
||||||
.Trashes
|
.Trashes
|
||||||
**/.Trashes
|
**/.Trashes
|
||||||
|
.codex
|
||||||
@@ -18,10 +18,29 @@
|
|||||||
- Box / Item / SubItem 基础 CRUD
|
- Box / Item / SubItem 基础 CRUD
|
||||||
- Box / Item / SubItem 单图上传、替换、删除、展示
|
- Box / Item / SubItem 单图上传、替换、删除、展示
|
||||||
- Box / Item / SubItem 全局搜索
|
- Box / Item / SubItem 全局搜索
|
||||||
|
- 最小 PWA 安装支持(主屏幕 / 桌面安装)
|
||||||
- Docker / Compose 长期运行
|
- Docker / Compose 长期运行
|
||||||
- SQLite 数据持久化
|
- SQLite 数据持久化
|
||||||
- 基础自动化测试
|
- 基础自动化测试
|
||||||
|
|
||||||
|
## PWA 安装支持
|
||||||
|
|
||||||
|
当前版本在不改变 FastAPI + Jinja2 SSR 结构的前提下,补充了最小可维护的 PWA 能力:
|
||||||
|
|
||||||
|
- 提供根路径 `manifest.webmanifest`
|
||||||
|
- 提供根路径 `service-worker.js`
|
||||||
|
- 在基础模板中注入 `manifest`、`theme-color`、`apple-touch-icon` 和安装相关 meta
|
||||||
|
- 支持 Android Chrome 添加到主屏幕
|
||||||
|
- 支持 iPhone Safari 添加到主屏幕
|
||||||
|
- 支持桌面 Chrome / Edge 安装为独立 app 窗口
|
||||||
|
|
||||||
|
当前新增的安装图标尺寸:
|
||||||
|
|
||||||
|
- `180x180`:Apple touch icon
|
||||||
|
- `192x192`:Android / Chromium 安装图标
|
||||||
|
- `512x512`:高分辨率安装图标
|
||||||
|
- `512x512`:maskable 图标
|
||||||
|
|
||||||
## 当前数据模型
|
## 当前数据模型
|
||||||
|
|
||||||
这个项目不是无限树结构,而是固定最多 3 级:
|
这个项目不是无限树结构,而是固定最多 3 级:
|
||||||
@@ -110,6 +129,9 @@ Box
|
|||||||
|
|
||||||
这一阶段仍然没有实现以下内容:
|
这一阶段仍然没有实现以下内容:
|
||||||
|
|
||||||
|
- 离线访问
|
||||||
|
- 离线缓存策略
|
||||||
|
- 离线数据同步
|
||||||
- 多图上传
|
- 多图上传
|
||||||
- OCR
|
- OCR
|
||||||
- AI 识别物品
|
- AI 识别物品
|
||||||
@@ -223,6 +245,22 @@ uvicorn app.main:app --reload --host 0.0.0.0 --port 10000
|
|||||||
http://localhost:10000
|
http://localhost:10000
|
||||||
```
|
```
|
||||||
|
|
||||||
|
本地开发验证 PWA 时,页面与安装元数据可以直接检查;如果要完整验证桌面安装体验,优先在 HTTPS 或受信任反向代理环境下测试。
|
||||||
|
|
||||||
|
## PWA 部署注意事项
|
||||||
|
|
||||||
|
- 生产环境应使用 HTTPS;Android Chrome 和桌面 Chrome / Edge 的安装能力通常要求安全上下文
|
||||||
|
- `manifest.webmanifest` 需要返回 `application/manifest+json`
|
||||||
|
- `service-worker.js` 需要从站点根路径返回,保证作用域覆盖整个应用
|
||||||
|
- 如果前面有 nginx 或其他反向代理,不要拦截或改写这两个根路径资源
|
||||||
|
- iPhone Safari 的“添加到主屏幕”主要依赖 meta 和 `apple-touch-icon`,不包含离线能力
|
||||||
|
|
||||||
|
## PWA 简单验收
|
||||||
|
|
||||||
|
1. Android Chrome:打开站点,确认浏览器菜单或地址栏出现“添加到主屏幕”或“安装应用”。
|
||||||
|
2. iPhone Safari:打开站点,点击分享菜单,确认可见“添加到主屏幕”。
|
||||||
|
3. Desktop Chrome / Edge:打开站点,确认地址栏或菜单中出现“安装应用”。
|
||||||
|
|
||||||
本地默认数据库位置:
|
本地默认数据库位置:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
|
|||||||
+48
-2
@@ -1,7 +1,8 @@
|
|||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
from fastapi import Depends, FastAPI, File, Form, HTTPException, Request, UploadFile, status
|
from fastapi import Depends, FastAPI, File, Form, HTTPException, Request, UploadFile, status
|
||||||
from fastapi.responses import RedirectResponse, Response
|
from fastapi.responses import FileResponse, RedirectResponse, Response
|
||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
from fastapi.templating import Jinja2Templates
|
from fastapi.templating import Jinja2Templates
|
||||||
from sqlalchemy import func, or_
|
from sqlalchemy import func, or_
|
||||||
@@ -12,6 +13,7 @@ from app.images import process_upload
|
|||||||
from app.models import Box, Item, SubItem
|
from app.models import Box, Item, SubItem
|
||||||
|
|
||||||
templates = Jinja2Templates(directory="app/templates")
|
templates = Jinja2Templates(directory="app/templates")
|
||||||
|
STATIC_DIR = Path("app/static")
|
||||||
|
|
||||||
|
|
||||||
def _clean_text(value: str | None) -> str | None:
|
def _clean_text(value: str | None) -> str | None:
|
||||||
@@ -86,6 +88,35 @@ def _wants_add_next(submit_action: str | None) -> bool:
|
|||||||
return submit_action == "save_and_add_next"
|
return submit_action == "save_and_add_next"
|
||||||
|
|
||||||
|
|
||||||
|
def _format_average(total: int, divisor: int) -> str:
|
||||||
|
if divisor == 0:
|
||||||
|
return "0.0"
|
||||||
|
return f"{total / divisor:.1f}"
|
||||||
|
|
||||||
|
|
||||||
|
def _build_boxes_overview_summary(db: Session) -> dict[str, int | str]:
|
||||||
|
box_count = db.query(func.count(Box.id)).scalar() or 0
|
||||||
|
item_count = db.query(func.count(Item.id)).scalar() or 0
|
||||||
|
subitem_count = db.query(func.count(SubItem.id)).scalar() or 0
|
||||||
|
container_item_count = (
|
||||||
|
db.query(func.count(Item.id))
|
||||||
|
.filter(Item.is_container.is_(True))
|
||||||
|
.scalar()
|
||||||
|
or 0
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"box_count": box_count,
|
||||||
|
"item_count": item_count,
|
||||||
|
"item_and_subitem_count": item_count + subitem_count,
|
||||||
|
"avg_items_per_box": _format_average(item_count, box_count),
|
||||||
|
"avg_subitems_per_container_item": _format_average(
|
||||||
|
subitem_count,
|
||||||
|
container_item_count,
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def _build_search_results(db: Session, query: str) -> list[dict]:
|
def _build_search_results(db: Session, query: str) -> list[dict]:
|
||||||
keyword = f"%{query.lower()}%"
|
keyword = f"%{query.lower()}%"
|
||||||
results: list[dict] = []
|
results: list[dict] = []
|
||||||
@@ -193,6 +224,20 @@ def create_app() -> FastAPI:
|
|||||||
def root() -> RedirectResponse:
|
def root() -> RedirectResponse:
|
||||||
return RedirectResponse(url="/boxes", status_code=status.HTTP_302_FOUND)
|
return RedirectResponse(url="/boxes", status_code=status.HTTP_302_FOUND)
|
||||||
|
|
||||||
|
@app.get("/manifest.webmanifest", include_in_schema=False)
|
||||||
|
def manifest() -> FileResponse:
|
||||||
|
return FileResponse(
|
||||||
|
path=STATIC_DIR / "manifest.webmanifest",
|
||||||
|
media_type="application/manifest+json",
|
||||||
|
)
|
||||||
|
|
||||||
|
@app.get("/service-worker.js", include_in_schema=False)
|
||||||
|
def service_worker() -> FileResponse:
|
||||||
|
return FileResponse(
|
||||||
|
path=STATIC_DIR / "service-worker.js",
|
||||||
|
media_type="application/javascript",
|
||||||
|
)
|
||||||
|
|
||||||
@app.get("/search")
|
@app.get("/search")
|
||||||
def search_page(
|
def search_page(
|
||||||
request: Request,
|
request: Request,
|
||||||
@@ -215,10 +260,11 @@ def create_app() -> FastAPI:
|
|||||||
@app.get("/boxes")
|
@app.get("/boxes")
|
||||||
def list_boxes(request: Request, db: Session = Depends(get_db)):
|
def list_boxes(request: Request, db: Session = Depends(get_db)):
|
||||||
boxes = db.query(Box).order_by(Box.id.desc()).all()
|
boxes = db.query(Box).order_by(Box.id.desc()).all()
|
||||||
|
summary = _build_boxes_overview_summary(db)
|
||||||
return templates.TemplateResponse(
|
return templates.TemplateResponse(
|
||||||
request=request,
|
request=request,
|
||||||
name="boxes/index.html",
|
name="boxes/index.html",
|
||||||
context={"page_title": "箱子", "boxes": boxes},
|
context={"page_title": "箱子", "boxes": boxes, "summary": summary},
|
||||||
)
|
)
|
||||||
|
|
||||||
@app.get("/boxes/new")
|
@app.get("/boxes/new")
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 837 B |
Binary file not shown.
|
After Width: | Height: | Size: 890 B |
Binary file not shown.
|
After Width: | Height: | Size: 2.9 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 3.0 KiB |
@@ -0,0 +1,31 @@
|
|||||||
|
{
|
||||||
|
"name": "搬家助手",
|
||||||
|
"short_name": "搬家助手",
|
||||||
|
"description": "用于记录搬家装箱内容并快速搜索的轻量工具。",
|
||||||
|
"start_url": "/boxes",
|
||||||
|
"scope": "/",
|
||||||
|
"display": "standalone",
|
||||||
|
"orientation": "portrait-primary",
|
||||||
|
"background_color": "#f4f4f4",
|
||||||
|
"theme_color": "#0b57d0",
|
||||||
|
"icons": [
|
||||||
|
{
|
||||||
|
"src": "/static/icons/icon-192.png",
|
||||||
|
"sizes": "192x192",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "any"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/static/icons/icon-512.png",
|
||||||
|
"sizes": "512x512",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "any"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/static/icons/icon-512-maskable.png",
|
||||||
|
"sizes": "512x512",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "maskable"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
self.addEventListener("install", function () {
|
||||||
|
self.skipWaiting();
|
||||||
|
});
|
||||||
|
|
||||||
|
self.addEventListener("activate", function (event) {
|
||||||
|
event.waitUntil(self.clients.claim());
|
||||||
|
});
|
||||||
@@ -231,6 +231,25 @@ button:focus-visible {
|
|||||||
gap: 10px;
|
gap: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.summary-section {
|
||||||
|
margin-bottom: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-block {
|
||||||
|
padding-top: 12px;
|
||||||
|
padding-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-list {
|
||||||
|
margin: 0;
|
||||||
|
padding-left: 18px;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-list li + li {
|
||||||
|
margin-top: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
.compact-row {
|
.compact-row {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: auto 1fr auto;
|
grid-template-columns: auto 1fr auto;
|
||||||
|
|||||||
@@ -3,7 +3,15 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<meta name="theme-color" content="#0b57d0">
|
||||||
|
<meta name="mobile-web-app-capable" content="yes">
|
||||||
|
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||||
|
<meta name="apple-mobile-web-app-title" content="搬家助手">
|
||||||
|
<meta name="apple-mobile-web-app-status-bar-style" content="default">
|
||||||
<title>{{ page_title or "搬家助手" }}</title>
|
<title>{{ page_title or "搬家助手" }}</title>
|
||||||
|
<link rel="manifest" href="/manifest.webmanifest">
|
||||||
|
<link rel="icon" href="/static/icons/icon-192.png" sizes="192x192" type="image/png">
|
||||||
|
<link rel="apple-touch-icon" href="/static/icons/apple-touch-icon.png" sizes="180x180">
|
||||||
<link rel="stylesheet" href="/static/style.css">
|
<link rel="stylesheet" href="/static/style.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
@@ -15,6 +23,12 @@
|
|||||||
{% block content %}{% endblock %}
|
{% block content %}{% endblock %}
|
||||||
</main>
|
</main>
|
||||||
<script>
|
<script>
|
||||||
|
if ("serviceWorker" in navigator) {
|
||||||
|
window.addEventListener("load", function () {
|
||||||
|
navigator.serviceWorker.register("/service-worker.js");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
document.addEventListener("click", function (event) {
|
document.addEventListener("click", function (event) {
|
||||||
const card = event.target.closest(".clickable-card[data-href]");
|
const card = event.target.closest(".clickable-card[data-href]");
|
||||||
if (!card) return;
|
if (!card) return;
|
||||||
|
|||||||
@@ -15,6 +15,22 @@
|
|||||||
<a class="button button-primary" href="/boxes/new">新建箱子</a>
|
<a class="button button-primary" href="/boxes/new">新建箱子</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<section class="stack summary-section">
|
||||||
|
<div class="section-heading">
|
||||||
|
<h2>当前概览</h2>
|
||||||
|
<p class="muted">快速查看当前装箱记录的核心统计。</p>
|
||||||
|
</div>
|
||||||
|
<section class="card summary-block">
|
||||||
|
<ul class="summary-list">
|
||||||
|
<li><strong>箱子总数:</strong>{{ summary.box_count }}</li>
|
||||||
|
<li><strong>物品总数(不含子物品):</strong>{{ summary.item_count }}</li>
|
||||||
|
<li><strong>物品总数(含子物品):</strong>{{ summary.item_and_subitem_count }}</li>
|
||||||
|
<li><strong>平均每箱物品数:</strong>{{ summary.avg_items_per_box }}</li>
|
||||||
|
<li><strong>平均每个容器型 Item 的子物品数:</strong>{{ summary.avg_subitems_per_container_item }}</li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
</section>
|
||||||
|
|
||||||
{% if boxes %}
|
{% if boxes %}
|
||||||
<div class="overview-grid">
|
<div class="overview-grid">
|
||||||
{% for box in boxes %}
|
{% for box in boxes %}
|
||||||
|
|||||||
@@ -44,21 +44,6 @@
|
|||||||
名称
|
名称
|
||||||
<input type="text" name="name" value="{{ item.name if item else '' }}" required autofocus>
|
<input type="text" name="name" value="{{ item.name if item else '' }}" required autofocus>
|
||||||
</label>
|
</label>
|
||||||
<label class="form-field">
|
|
||||||
数量
|
|
||||||
<input type="number" name="quantity" min="0" value="{{ item.quantity if item and item.quantity is not none else '1' }}">
|
|
||||||
</label>
|
|
||||||
<label class="checkbox-row">
|
|
||||||
<input type="checkbox" name="is_container" {% if item and item.is_container %}checked{% endif %}>
|
|
||||||
这个物品本身是一个小容器
|
|
||||||
</label>
|
|
||||||
<div class="checkbox-help">
|
|
||||||
勾选后,这个 Item 将作为“第二层容器”,后续可以继续往里面添加最后一级的 SubItem。
|
|
||||||
</div>
|
|
||||||
<label class="form-field">
|
|
||||||
备注
|
|
||||||
<textarea name="note" rows="4">{{ item.note if item and item.note else '' }}</textarea>
|
|
||||||
</label>
|
|
||||||
<label class="form-field">
|
<label class="form-field">
|
||||||
图片
|
图片
|
||||||
<input type="file" name="image_file" accept="image/*">
|
<input type="file" name="image_file" accept="image/*">
|
||||||
@@ -77,6 +62,21 @@
|
|||||||
</button>
|
</button>
|
||||||
</section>
|
</section>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
<label class="form-field">
|
||||||
|
数量
|
||||||
|
<input type="number" name="quantity" min="0" value="{{ item.quantity if item and item.quantity is not none else '1' }}">
|
||||||
|
</label>
|
||||||
|
<label class="checkbox-row">
|
||||||
|
<input type="checkbox" name="is_container" {% if item and item.is_container %}checked{% endif %}>
|
||||||
|
这个物品本身是一个小容器
|
||||||
|
</label>
|
||||||
|
<div class="checkbox-help">
|
||||||
|
勾选后,这个 Item 将作为“第二层容器”,后续可以继续往里面添加最后一级的 SubItem。
|
||||||
|
</div>
|
||||||
|
<label class="form-field">
|
||||||
|
备注
|
||||||
|
<textarea name="note" rows="4">{{ item.note if item and item.note else '' }}</textarea>
|
||||||
|
</label>
|
||||||
<div class="form-actions">
|
<div class="form-actions">
|
||||||
<button type="submit" name="submit_action" value="save" class="button button-primary">{{ submit_label }}</button>
|
<button type="submit" name="submit_action" value="save" class="button button-primary">{{ submit_label }}</button>
|
||||||
{% if not item %}
|
{% if not item %}
|
||||||
|
|||||||
@@ -70,6 +70,11 @@
|
|||||||
<span>上级容器:{{ item.name }}</span>
|
<span>上级容器:{{ item.name }}</span>
|
||||||
</div>
|
</div>
|
||||||
{% if subitem.note %}<p class="row-note">备注:{{ subitem.note }}</p>{% endif %}
|
{% if subitem.note %}<p class="row-note">备注:{{ subitem.note }}</p>{% endif %}
|
||||||
|
<div class="actions">
|
||||||
|
<form method="post" action="/subitems/{{ subitem.id }}/delete">
|
||||||
|
<button type="submit" class="button button-danger button-small">删除子物品</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
|||||||
@@ -46,14 +46,6 @@
|
|||||||
名称
|
名称
|
||||||
<input type="text" name="name" value="{{ subitem.name if subitem else '' }}" required autofocus>
|
<input type="text" name="name" value="{{ subitem.name if subitem else '' }}" required autofocus>
|
||||||
</label>
|
</label>
|
||||||
<label class="form-field">
|
|
||||||
数量
|
|
||||||
<input type="number" name="quantity" min="0" value="{{ subitem.quantity if subitem and subitem.quantity is not none else '1' }}">
|
|
||||||
</label>
|
|
||||||
<label class="form-field">
|
|
||||||
备注
|
|
||||||
<textarea name="note" rows="4">{{ subitem.note if subitem and subitem.note else '' }}</textarea>
|
|
||||||
</label>
|
|
||||||
<label class="form-field">
|
<label class="form-field">
|
||||||
图片
|
图片
|
||||||
<input type="file" name="image_file" accept="image/*">
|
<input type="file" name="image_file" accept="image/*">
|
||||||
@@ -72,6 +64,14 @@
|
|||||||
</button>
|
</button>
|
||||||
</section>
|
</section>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
<label class="form-field">
|
||||||
|
数量
|
||||||
|
<input type="number" name="quantity" min="0" value="{{ subitem.quantity if subitem and subitem.quantity is not none else '1' }}">
|
||||||
|
</label>
|
||||||
|
<label class="form-field">
|
||||||
|
备注
|
||||||
|
<textarea name="note" rows="4">{{ subitem.note if subitem and subitem.note else '' }}</textarea>
|
||||||
|
</label>
|
||||||
<div class="form-actions">
|
<div class="form-actions">
|
||||||
<button type="submit" name="submit_action" value="save" class="button button-primary">{{ submit_label }}</button>
|
<button type="submit" name="submit_action" value="save" class="button button-primary">{{ submit_label }}</button>
|
||||||
{% if not subitem %}
|
{% if not subitem %}
|
||||||
|
|||||||
@@ -111,6 +111,39 @@ def test_boxes_page_uses_relative_stylesheet_path(client):
|
|||||||
assert "http://" not in response.text.split("/static/style.css")[0]
|
assert "http://" not in response.text.split("/static/style.css")[0]
|
||||||
|
|
||||||
|
|
||||||
|
def test_base_template_includes_pwa_metadata(client):
|
||||||
|
response = client.get("/boxes")
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert 'rel="manifest" href="/manifest.webmanifest"' in response.text
|
||||||
|
assert 'name="theme-color" content="#0b57d0"' in response.text
|
||||||
|
assert 'rel="apple-touch-icon" href="/static/icons/apple-touch-icon.png"' in response.text
|
||||||
|
assert 'navigator.serviceWorker.register("/service-worker.js")' in response.text
|
||||||
|
|
||||||
|
|
||||||
|
def test_manifest_is_served_with_pwa_fields(client):
|
||||||
|
response = client.get("/manifest.webmanifest")
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.headers["content-type"].startswith("application/manifest+json")
|
||||||
|
|
||||||
|
manifest = response.json()
|
||||||
|
assert manifest["name"] == "搬家助手"
|
||||||
|
assert manifest["short_name"] == "搬家助手"
|
||||||
|
assert manifest["start_url"] == "/boxes"
|
||||||
|
assert manifest["display"] == "standalone"
|
||||||
|
assert any(icon["sizes"] == "192x192" for icon in manifest["icons"])
|
||||||
|
assert any(icon["purpose"] == "maskable" for icon in manifest["icons"])
|
||||||
|
|
||||||
|
|
||||||
|
def test_service_worker_is_served_from_root(client):
|
||||||
|
response = client.get("/service-worker.js")
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.headers["content-type"].startswith("application/javascript")
|
||||||
|
assert 'self.addEventListener("install"' in response.text
|
||||||
|
|
||||||
|
|
||||||
def test_boxes_overview_card_shows_note_and_item_count_without_room_or_status(client, db_session):
|
def test_boxes_overview_card_shows_note_and_item_count_without_room_or_status(client, db_session):
|
||||||
box = Box(
|
box = Box(
|
||||||
name="Kitchen Box",
|
name="Kitchen Box",
|
||||||
@@ -145,6 +178,53 @@ def test_boxes_overview_renders_cleanly_when_note_is_empty(client, db_session):
|
|||||||
assert "状态:" not in response.text
|
assert "状态:" not in response.text
|
||||||
|
|
||||||
|
|
||||||
|
def test_boxes_overview_summary_shows_expected_counts_and_averages(client, db_session):
|
||||||
|
first_box = Box(name="卧室箱")
|
||||||
|
second_box = Box(name="书房箱")
|
||||||
|
regular_item = Item(name="书", box=first_box, is_container=False)
|
||||||
|
container_item = Item(name="配件袋", box=first_box, is_container=True)
|
||||||
|
second_container_item = Item(name="文件袋", box=second_box, is_container=True)
|
||||||
|
db_session.add_all(
|
||||||
|
[
|
||||||
|
first_box,
|
||||||
|
second_box,
|
||||||
|
regular_item,
|
||||||
|
container_item,
|
||||||
|
second_container_item,
|
||||||
|
SubItem(name="转接头", parent_item=container_item),
|
||||||
|
SubItem(name="数据线", parent_item=container_item),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
db_session.commit()
|
||||||
|
|
||||||
|
response = client.get("/boxes")
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert "当前概览" in response.text
|
||||||
|
assert "箱子总数" in response.text
|
||||||
|
assert "物品总数(不含子物品)" in response.text
|
||||||
|
assert "物品总数(含子物品)" in response.text
|
||||||
|
assert "平均每箱物品数" in response.text
|
||||||
|
assert "平均每个容器型 Item 的子物品数" in response.text
|
||||||
|
assert "箱子总数:</strong>2" in response.text
|
||||||
|
assert "物品总数(不含子物品):</strong>3" in response.text
|
||||||
|
assert "物品总数(含子物品):</strong>5" in response.text
|
||||||
|
assert "平均每箱物品数:</strong>1.5" in response.text
|
||||||
|
assert "平均每个容器型 Item 的子物品数:</strong>1.0" in response.text
|
||||||
|
|
||||||
|
|
||||||
|
def test_boxes_overview_summary_handles_empty_data_safely(client):
|
||||||
|
response = client.get("/boxes")
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert "当前概览" in response.text
|
||||||
|
assert "箱子总数:</strong>0" in response.text
|
||||||
|
assert "物品总数(不含子物品):</strong>0" in response.text
|
||||||
|
assert "物品总数(含子物品):</strong>0" in response.text
|
||||||
|
assert "平均每箱物品数:</strong>0.0" in response.text
|
||||||
|
assert "平均每个容器型 Item 的子物品数:</strong>0.0" in response.text
|
||||||
|
|
||||||
|
|
||||||
def test_can_create_box(client, db_session):
|
def test_can_create_box(client, db_session):
|
||||||
response = create_box(client, name="Kitchen Box")
|
response = create_box(client, name="Kitchen Box")
|
||||||
|
|
||||||
@@ -842,6 +922,7 @@ def test_new_item_page_shows_clear_context_and_default_quantity(client, db_sessi
|
|||||||
assert "主卧箱" in response.text
|
assert "主卧箱" in response.text
|
||||||
assert 'name="quantity"' in response.text
|
assert 'name="quantity"' in response.text
|
||||||
assert 'value="1"' in response.text
|
assert 'value="1"' in response.text
|
||||||
|
assert response.text.index('name="name"') < response.text.index('name="image_file"') < response.text.index('name="quantity"')
|
||||||
assert "这个物品本身是一个小容器" in response.text
|
assert "这个物品本身是一个小容器" in response.text
|
||||||
assert "保存并添加下一个" in response.text
|
assert "保存并添加下一个" in response.text
|
||||||
|
|
||||||
@@ -860,6 +941,7 @@ def test_new_subitem_page_shows_clear_context_and_default_quantity(client, db_se
|
|||||||
assert "文件袋" in response.text
|
assert "文件袋" in response.text
|
||||||
assert 'name="quantity"' in response.text
|
assert 'name="quantity"' in response.text
|
||||||
assert 'value="1"' in response.text
|
assert 'value="1"' in response.text
|
||||||
|
assert response.text.index('name="name"') < response.text.index('name="image_file"') < response.text.index('name="quantity"')
|
||||||
assert "保存并添加下一个" in response.text
|
assert "保存并添加下一个" in response.text
|
||||||
|
|
||||||
|
|
||||||
@@ -893,6 +975,7 @@ def test_item_detail_page_renders_clear_hierarchy(client, db_session):
|
|||||||
assert "书房箱" in response.text
|
assert "书房箱" in response.text
|
||||||
assert "SubItem" in response.text
|
assert "SubItem" in response.text
|
||||||
assert f'data-href="/subitems/{subitem.id}/edit"' in response.text
|
assert f'data-href="/subitems/{subitem.id}/edit"' in response.text
|
||||||
|
assert f'action="/subitems/{subitem.id}/delete"' in response.text
|
||||||
assert "overview-grid" in response.text
|
assert "overview-grid" in response.text
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user