- src/muse 新版全模块(装配/共享/上下文/任务运行/作品规划/故事世界/正文写作/审校修订/知识方法/作者经验/效果评测/交付连载/资料研究/正式变更/元数据/接入/基础设施/编排)+ 测试树(单元/契约/集成/架构/迁移/端到端/夹具) - 129 项功能全部实现与自动验证(功能覆盖.json/矩阵),含 W31 补齐的规则与代价/节奏安排/伏笔与承诺 - 旧实现按处置清单退出(702 条中 324 删,保护合同与未迁移条目留存有据);web/app.py 旧工作台退役,新工作台为唯一写入口 - 数据库/旧库迁移:真实旧库内容批次迁移链(端点守卫/PG作品正文映射/质量资产缺省投影) - 运行手册 docs/运行手册.md;W30 本机服务阶段一已运行(infra PG 为正式内容权威) - R2 执行证据与私有运行材料在 .agents.local/改造/R2-20260909/(不入库)
299 lines
11 KiB
Python
299 lines
11 KiB
Python
"""两作品经浏览器带走完整备份,再在隔离新库读取决定并续接交付。"""
|
||
|
||
import json
|
||
import os
|
||
import socket
|
||
import subprocess
|
||
import sys
|
||
import time
|
||
from contextlib import contextmanager
|
||
from pathlib import Path
|
||
from uuid import uuid4
|
||
|
||
import httpx
|
||
import pytest
|
||
from psycopg.conninfo import conninfo_to_dict, make_conninfo
|
||
|
||
from muse.交付连载.接口 import 交付材料, 定稿请求, 章节版本
|
||
from muse.作品规划.接口 import 档案保存
|
||
from muse.元数据.接口 import 元数据服务, 导入内置结构
|
||
from muse.共享.调用身份 import 内容用途, 用途, 调用身份
|
||
from muse.启动 import 构建
|
||
from muse.基础设施.备份恢复 import 创建备份, 恢复备份, 解包备份
|
||
from muse.正式变更.接口 import 作者动作
|
||
from muse.正文写作.接口 import 候选定位, 文本节点, 正文草稿, 段落
|
||
from muse.编排.定稿交付 import 发起定稿导出
|
||
from muse.资源加载 import 加载清单
|
||
from muse.配置 import 应用配置, 数据库引用
|
||
|
||
pytestmark = [pytest.mark.数据库, pytest.mark.浏览器, pytest.mark.timeout(150)]
|
||
根 = Path(__file__).resolve().parents[2]
|
||
|
||
|
||
@contextmanager
|
||
def 启动工作台(config, directory, base):
|
||
with (directory / "server.log").open("ab") as log:
|
||
process = subprocess.Popen(
|
||
[sys.executable, "-I", "-m", "muse", "服务", str(config)],
|
||
cwd=directory,
|
||
stdout=log,
|
||
stderr=subprocess.STDOUT,
|
||
)
|
||
try:
|
||
for _ in range(100):
|
||
if process.poll() is not None:
|
||
pytest.fail("验收工作台提前退出,请查看server.log")
|
||
try:
|
||
if httpx.get(base + "/ready", trust_env=False, timeout=1).status_code == 200:
|
||
break
|
||
except httpx.TransportError:
|
||
pass
|
||
time.sleep(0.1)
|
||
else:
|
||
pytest.fail("验收工作台未就绪")
|
||
yield
|
||
finally:
|
||
process.terminate()
|
||
try:
|
||
process.wait(10)
|
||
except subprocess.TimeoutExpired:
|
||
process.kill()
|
||
process.wait(5)
|
||
pytest.fail("验收工作台未正常停止")
|
||
|
||
|
||
def 写应用配置(path, reference, password, base, backup_root):
|
||
path.write_text(
|
||
'["数据库"]\n"取值方式"="受控存储"\n"位置"='
|
||
+ json.dumps(reference.位置)
|
||
+ '\n["资源"]\n"发布身份"='
|
||
+ json.dumps(加载清单()["构建身份"])
|
||
+ '\n["文件"]\n"备份目录"='
|
||
+ json.dumps(str(backup_root))
|
||
+ '\n["HTTP"]\n"口令文件"='
|
||
+ json.dumps(str(password))
|
||
+ '\n"作者ID"="restore-author"\n"端口"='
|
||
+ base.rsplit(":", 1)[1]
|
||
+ '\n"公开地址"='
|
||
+ json.dumps(base)
|
||
+ '\n"允许来源"=['
|
||
+ json.dumps(base)
|
||
+ "]\n"
|
||
)
|
||
path.chmod(0o600)
|
||
|
||
|
||
def test_两作品浏览器下载后恢复决定原文并续跑原交付__29d001(应用测试库, 新空目标库, tmp_path):
|
||
if os.environ.get("MUSE_RUN_BROWSER") != "1":
|
||
pytest.skip("需显式启用浏览器验收")
|
||
pools = 应用测试库
|
||
with pools[用途.维护].连接() as conn, conn.transaction():
|
||
导入内置结构(元数据服务(conn))
|
||
author = 调用身份("restore-author", None, 用途.生产, 内容用途.生成)
|
||
app = 构建(应用配置(pools[用途.生产].引用, 加载清单()["构建身份"]))
|
||
works, body, delivery = app.要求作品(), app.要求正文(), app.要求交付()
|
||
expected = []
|
||
for number in range(2):
|
||
wid = f"restore-work-{number}"
|
||
works.保存档案(
|
||
author,
|
||
wid,
|
||
档案保存(
|
||
wid,
|
||
0,
|
||
{"名称": f"合成恢复作品{number}"},
|
||
works.新档案结构(author, wid, "work_core", 1),
|
||
),
|
||
)
|
||
chapters = []
|
||
for chapter in range(2):
|
||
cid = f"{wid}-chapter-{chapter}"
|
||
works.添加章节(author, cid, wid, cid, f"第{chapter + 1}章", 预期目录版本=chapter)
|
||
body.保存人工(
|
||
author,
|
||
cid + "-body",
|
||
cid,
|
||
0,
|
||
正文草稿(
|
||
(
|
||
段落("p1", (文本节点(f"作品{number}章{chapter}原文🙂e\u0301"),)),
|
||
段落("empty", ()),
|
||
段落("p2", (文本节点("保留末句。"),)),
|
||
)
|
||
),
|
||
)
|
||
proposed = body.创建人工候选(
|
||
author,
|
||
cid + "-candidate",
|
||
cid,
|
||
1,
|
||
正文草稿(
|
||
(
|
||
段落("p1", (文本节点(f"作品{number}章{chapter}确认正文🙂e\u0301"),)),
|
||
段落("empty", ()),
|
||
段落("p2", (文本节点("保留末句。"),)),
|
||
)
|
||
),
|
||
)
|
||
candidate = body.读取候选(author, proposed["results"][0]["candidate_id"])
|
||
location = 候选定位(
|
||
candidate["candidate_id"],
|
||
1,
|
||
candidate["candidate_hash"],
|
||
candidate["document_id"],
|
||
1,
|
||
)
|
||
review = body.打开候选审阅(author, location)
|
||
receipt = body.决定候选(
|
||
author,
|
||
cid + "-adopt",
|
||
location,
|
||
作者动作.采纳,
|
||
review["review_id"],
|
||
review["review_hash"],
|
||
("document",),
|
||
)
|
||
current = body.读取正文(author, cid)
|
||
chapters.append(章节版本(cid, current["revision"], current["document_hash"]))
|
||
expected.append(
|
||
{
|
||
"chapter": cid,
|
||
"body": current,
|
||
"location": location,
|
||
"review": review,
|
||
"receipt": receipt,
|
||
}
|
||
)
|
||
did = str(uuid4())
|
||
delivery.冻结定稿(
|
||
author,
|
||
wid + "-freeze",
|
||
定稿请求(
|
||
did,
|
||
wid,
|
||
2,
|
||
tuple(chapters),
|
||
交付材料(f"作品{number}", "合成作者", "简介", "梗概", "人物"),
|
||
),
|
||
)
|
||
queued = 发起定稿导出(app, author, wid + "-export", did, ["txt", "epub"])
|
||
expected[-1].update(delivery_id=did, task_id=queued["task_id"])
|
||
|
||
backups, raw, drafts = (tmp_path / n for n in ("backups", "raw", "drafts"))
|
||
for directory in (backups, raw, drafts):
|
||
directory.mkdir(mode=0o700)
|
||
(raw / "evidence.bin").write_bytes(b"\x00\xffraw-evidence")
|
||
(drafts / "草稿.md").write_text("仅用于验收的私人草稿。\n")
|
||
archive = 创建备份(
|
||
应用配置(
|
||
pools[用途.维护].引用,
|
||
加载清单()["构建身份"],
|
||
运行用途=用途.维护,
|
||
原文暂存=str(raw),
|
||
探索草稿=str(drafts),
|
||
),
|
||
backups / "完整备份",
|
||
)
|
||
broken = backups / "不完整备份"
|
||
broken.mkdir(mode=0o700)
|
||
(broken / "manifest.json").write_text("{}")
|
||
(broken / "manifest.json").chmod(0o600)
|
||
password = tmp_path / "password"
|
||
password.write_text("synthetic-backup-browser")
|
||
password.chmod(0o600)
|
||
with socket.socket() as sock:
|
||
sock.bind(("127.0.0.1", 0))
|
||
base = f"http://127.0.0.1:{sock.getsockname()[1]}"
|
||
config = tmp_path / "source.toml"
|
||
写应用配置(config, pools[用途.生产].引用, password, base, backups)
|
||
output = Path(os.environ.get("MUSE_BROWSER_ARTIFACTS", str(tmp_path / "browser")))
|
||
output.mkdir(parents=True, exist_ok=True)
|
||
downloaded = output / "download.tar"
|
||
env = {
|
||
**os.environ,
|
||
"MUSE_WORKBENCH_URL": base,
|
||
"MUSE_AUTHOR_PASSWORD_FILE": str(password),
|
||
"MUSE_BACKUP_ID": archive["backup_id"],
|
||
"MUSE_BACKUP_DOWNLOAD_PATH": str(downloaded),
|
||
"MUSE_BROWSER_EXECUTABLE": "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
|
||
}
|
||
with 启动工作台(config, tmp_path, base), (output / "浏览器.json").open("w") as report:
|
||
run = subprocess.run(
|
||
[
|
||
"pnpm",
|
||
"exec",
|
||
"playwright",
|
||
"test",
|
||
"备份恢复.spec.ts",
|
||
"--reporter=json",
|
||
"--output",
|
||
str(output / "产物"),
|
||
],
|
||
cwd=根 / "web",
|
||
env=env,
|
||
stdout=report,
|
||
stderr=subprocess.STDOUT,
|
||
timeout=90,
|
||
)
|
||
assert run.returncode == 0, "浏览器未通过,见浏览器.json"
|
||
unpacked = tmp_path / "download-unpacked"
|
||
解包备份(downloaded, unpacked, archive["backup_id"])
|
||
restored_raw, restored_drafts = tmp_path / "restored-raw", tmp_path / "restored-drafts"
|
||
restored = 恢复备份(
|
||
unpacked,
|
||
应用配置(
|
||
新空目标库.工厂.引用,
|
||
加载清单()["构建身份"],
|
||
运行用途=用途.维护,
|
||
原文暂存=str(restored_raw),
|
||
探索草稿=str(restored_drafts),
|
||
),
|
||
archive["backup_id"],
|
||
)
|
||
assert restored["status"] == "verified"
|
||
params = conninfo_to_dict(Path(新空目标库.工厂.引用.位置).read_text())
|
||
params["user"] = "muse_app"
|
||
reference = tmp_path / "restored-reference"
|
||
reference.write_text(make_conninfo(**params))
|
||
reference.chmod(0o600)
|
||
restored_reference = 数据库引用("受控存储", str(reference))
|
||
restored_app = 构建(应用配置(restored_reference, 加载清单()["构建身份"]))
|
||
for item in expected:
|
||
assert restored_app.要求正文().读取正文(author, item["chapter"]) == item["body"]
|
||
review = item["review"]
|
||
assert (
|
||
restored_app.要求正文().决定候选(
|
||
author,
|
||
item["chapter"] + "-adopt",
|
||
item["location"],
|
||
作者动作.采纳,
|
||
review["review_id"],
|
||
review["review_hash"],
|
||
("document",),
|
||
)
|
||
== item["receipt"]
|
||
)
|
||
写应用配置(config, restored_reference, password, base, backups)
|
||
with (
|
||
启动工作台(config, tmp_path, base),
|
||
httpx.Client(
|
||
base_url=base,
|
||
headers={"origin": base},
|
||
trust_env=False,
|
||
) as client,
|
||
):
|
||
assert (
|
||
client.post("/api/v1/session", json={"password": password.read_text()}).status_code
|
||
== 200
|
||
)
|
||
for item in expected:
|
||
if "task_id" not in item:
|
||
continue
|
||
path = "/api/v1/deliveries/" + item["delivery_id"]
|
||
result = client.post(path + "/exports/" + item["task_id"] + "/advance")
|
||
assert result.status_code == 200, result.text
|
||
assert result.json()["state"] == "completed"
|
||
assert client.get(path + "/files/epub").content[:2] == b"PK"
|
||
assert (restored_raw / "evidence.bin").read_bytes() == (raw / "evidence.bin").read_bytes()
|
||
assert (restored_drafts / "草稿.md").read_bytes() == (drafts / "草稿.md").read_bytes()
|