test_phase_two_workspace.py 25 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661
  1. from __future__ import annotations
  2. import asyncio
  3. import os
  4. from dataclasses import replace
  5. import pytest
  6. from sqlalchemy import func, select
  7. from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
  8. from script_build_host.domain.artifacts import ArtifactKind, ArtifactState
  9. from script_build_host.domain.phase_two_artifacts import CandidateLineageV1, ParagraphArtifactV1
  10. from script_build_host.domain.workspaces import CandidateWriteContext, WorkspaceError
  11. from script_build_host.infrastructure.legacy_tables import (
  12. script_build_element,
  13. script_build_paragraph,
  14. script_build_paragraph_element,
  15. script_build_task_plan_step,
  16. )
  17. from script_build_host.infrastructure.tables import artifact_version_table
  18. from script_build_host.repositories.sqlalchemy import (
  19. SqlAlchemyScriptBusinessArtifactRepository,
  20. )
  21. from script_build_host.repositories.workspace import (
  22. SqlAlchemyCandidateWorkspaceRepository,
  23. )
  24. def _context(attempt: str, *, task: str = "task-1", build: int = 1) -> CandidateWriteContext:
  25. return CandidateWriteContext(
  26. script_build_id=build,
  27. task_id=task,
  28. attempt_id=attempt,
  29. spec_version=1,
  30. objective="produce the opening increment",
  31. input_refs=("script-build://artifact-versions/90",),
  32. write_scope=("script-build://scopes/opening",),
  33. )
  34. def _lineage(
  35. *,
  36. base_ref: str | None = None,
  37. base_digest: str | None = None,
  38. base_revision: int | None = None,
  39. ) -> CandidateLineageV1:
  40. return CandidateLineageV1(
  41. scope_ref="script-build://scopes/opening",
  42. input_snapshot_ref="script-build://inputs/11",
  43. input_closure_digest="sha256:" + "a" * 64,
  44. write_scope=("script-build://scopes/opening",),
  45. base_artifact_ref=base_ref,
  46. base_artifact_digest=base_digest,
  47. base_revision=base_revision,
  48. )
  49. @pytest.mark.mysql
  50. @pytest.mark.asyncio
  51. async def test_mysql_workspace_writes_only_through_candidate_view() -> None:
  52. dsn = os.environ.get("SCRIPT_BUILD_TEST_MYSQL_DSN")
  53. if not dsn:
  54. pytest.skip("SCRIPT_BUILD_TEST_MYSQL_DSN is not configured")
  55. engine = create_async_engine(dsn, isolation_level="READ COMMITTED", pool_pre_ping=True)
  56. sessions = async_sessionmaker(engine, expire_on_commit=False)
  57. artifacts = SqlAlchemyScriptBusinessArtifactRepository(sessions)
  58. repository = SqlAlchemyCandidateWorkspaceRepository(sessions, artifacts)
  59. context = _context("mysql-candidate-view", build=920000)
  60. try:
  61. workspace = await repository.get_or_create(context, artifact_kind=ArtifactKind.PARAGRAPH)
  62. paragraph_id = await repository.create_paragraph(
  63. context,
  64. paragraph_index=1,
  65. name="candidate-only",
  66. content_range={},
  67. )
  68. async with sessions() as session:
  69. branch = await session.scalar(
  70. select(script_build_paragraph.c.branch_id).where(
  71. script_build_paragraph.c.id == paragraph_id
  72. )
  73. )
  74. assert branch == workspace.branch_id > 0
  75. assert repository._paragraphs.name == "script_build_candidate_paragraph_v"
  76. finally:
  77. await engine.dispose()
  78. @pytest.mark.asyncio
  79. async def test_workspace_allocates_positive_branch_and_freezes_full_paragraph(database) -> None:
  80. _, sessions = database
  81. artifacts = SqlAlchemyScriptBusinessArtifactRepository(sessions)
  82. repository = SqlAlchemyCandidateWorkspaceRepository(sessions, artifacts)
  83. context = _context("attempt-paragraph")
  84. workspace = await repository.get_or_create(context, artifact_kind=ArtifactKind.PARAGRAPH)
  85. assert workspace.branch_id == workspace.artifact_version_id > 0
  86. assert (
  87. await repository.get_or_create(context, artifact_kind=ArtifactKind.PARAGRAPH) == workspace
  88. )
  89. parent = await repository.create_paragraph(
  90. context,
  91. paragraph_index=1,
  92. name="opening",
  93. content_range={"topics": [1, 2]},
  94. theme_elements=({"维度": "主题", "原子点": "冲突", "维度类型": "主维度"},),
  95. form_elements=({"维度": "形式", "原子点": "对照", "维度类型": "主维度"},),
  96. )
  97. child = await repository.create_paragraph(
  98. context,
  99. paragraph_index=2,
  100. name="opening detail",
  101. content_range={"topics": [1]},
  102. level=2,
  103. parent_paragraph_id=parent,
  104. )
  105. await repository.batch_update_paragraphs(
  106. context,
  107. [
  108. {
  109. "paragraph_id": parent,
  110. "theme": "a concrete conflict",
  111. "form": "contrast",
  112. "function": "hook",
  113. "feeling": "curiosity",
  114. "description": "bounded description",
  115. "full_description": "fully realized opening",
  116. }
  117. ],
  118. )
  119. added = await repository.append_paragraph_atoms(
  120. context,
  121. paragraph_id=child,
  122. column="feeling_elements",
  123. atoms=({"维度": "情绪", "原子点": "期待"},),
  124. )
  125. assert added == 1
  126. version, reference = await repository.freeze(context, lineage=_lineage())
  127. assert version.state is ArtifactState.FROZEN
  128. assert reference.kind == ArtifactKind.PARAGRAPH.value
  129. assert isinstance(version.artifact, ParagraphArtifactV1)
  130. assert len(version.artifact.paragraphs) == 2
  131. assert version.artifact.paragraphs[0].full_description == "fully realized opening"
  132. with pytest.raises(WorkspaceError, match="ATTEMPT_WORKSPACE_FROZEN"):
  133. await repository.create_paragraph(
  134. context,
  135. paragraph_index=3,
  136. name="late mutation",
  137. content_range={"topics": [2]},
  138. )
  139. replayed, replay_ref = await repository.freeze(context, lineage=_lineage())
  140. assert (replayed.artifact_version_id, replay_ref) == (
  141. version.artifact_version_id,
  142. reference,
  143. )
  144. with pytest.raises(WorkspaceError, match="STALE_BASE_REVISION"):
  145. await repository.freeze(
  146. context,
  147. lineage=CandidateLineageV1(
  148. scope_ref="script-build://scopes/opening",
  149. input_snapshot_ref="script-build://inputs/12",
  150. input_closure_digest="sha256:" + "b" * 64,
  151. write_scope=("script-build://scopes/opening",),
  152. ),
  153. )
  154. async with sessions() as session:
  155. plan = (
  156. (
  157. await session.execute(
  158. select(script_build_task_plan_step).where(
  159. script_build_task_plan_step.c.script_build_id == 1,
  160. script_build_task_plan_step.c.branch_id == workspace.branch_id,
  161. )
  162. )
  163. )
  164. .mappings()
  165. .one()
  166. )
  167. assert plan["step_key"] == "1"
  168. assert plan["round_index"] is None
  169. assert plan["status"] == "完成"
  170. @pytest.mark.asyncio
  171. async def test_semantic_write_scope_is_enforced_before_atomic_batch(database) -> None:
  172. _, sessions = database
  173. artifacts = SqlAlchemyScriptBusinessArtifactRepository(sessions)
  174. repository = SqlAlchemyCandidateWorkspaceRepository(sessions, artifacts)
  175. full_context = _context("write-scope")
  176. await repository.get_or_create(full_context, artifact_kind=ArtifactKind.PARAGRAPH)
  177. first = await repository.create_paragraph(
  178. full_context, paragraph_index=1, name="one", content_range={"topics": [1]}
  179. )
  180. second = await repository.create_paragraph(
  181. full_context, paragraph_index=2, name="two", content_range={"topics": [2]}
  182. )
  183. bounded = replace(full_context, write_scope=(f"script-build://writes/paragraphs/{first}",))
  184. with pytest.raises(WorkspaceError, match="WRITE_SCOPE_VIOLATION"):
  185. await repository.batch_update_paragraphs(
  186. bounded,
  187. (
  188. {"paragraph_id": first, "description": "authorized but must roll back"},
  189. {"paragraph_id": second, "description": "outside scope"},
  190. ),
  191. )
  192. snapshot = await repository.snapshot(full_context)
  193. assert [item.description for item in snapshot.paragraphs] == [None, None]
  194. with pytest.raises(WorkspaceError, match="WRITE_SCOPE_VIOLATION"):
  195. await repository.create_element(
  196. bounded,
  197. name="not allowed",
  198. dimension_primary="实质",
  199. dimension_secondary="detail",
  200. )
  201. @pytest.mark.asyncio
  202. async def test_content_range_scope_must_cover_every_key_and_value(database) -> None:
  203. _, sessions = database
  204. artifacts = SqlAlchemyScriptBusinessArtifactRepository(sessions)
  205. repository = SqlAlchemyCandidateWorkspaceRepository(sessions, artifacts)
  206. context = replace(
  207. _context("content-range-scope"),
  208. write_scope=("script-build://writes/content-ranges/topics/1",),
  209. )
  210. await repository.get_or_create(context, artifact_kind=ArtifactKind.PARAGRAPH)
  211. with pytest.raises(WorkspaceError, match="WRITE_SCOPE_VIOLATION"):
  212. await repository.create_paragraph(
  213. context,
  214. paragraph_index=1,
  215. name="partially authorized",
  216. content_range={"topics": [1, 2], "sections": ["ending"]},
  217. )
  218. snapshot = await repository.snapshot(context)
  219. assert snapshot.paragraphs == ()
  220. @pytest.mark.asyncio
  221. async def test_discard_marks_workspace_and_legacy_plan_blocked(database) -> None:
  222. _, sessions = database
  223. artifacts = SqlAlchemyScriptBusinessArtifactRepository(sessions)
  224. repository = SqlAlchemyCandidateWorkspaceRepository(sessions, artifacts)
  225. context = _context("discarded-attempt")
  226. workspace = await repository.get_or_create(context, artifact_kind=ArtifactKind.PARAGRAPH)
  227. await repository.create_paragraph(
  228. context, paragraph_index=1, name="draft", content_range={"topics": [1]}
  229. )
  230. discarded = await repository.discard(context, reason="validator hard defect")
  231. assert discarded.state is ArtifactState.DISCARDED
  232. assert (
  233. await repository.discard(context, reason="idempotent replay")
  234. ).state is ArtifactState.DISCARDED
  235. async with sessions() as session:
  236. plan = (
  237. (
  238. await session.execute(
  239. select(script_build_task_plan_step).where(
  240. script_build_task_plan_step.c.script_build_id == context.script_build_id,
  241. script_build_task_plan_step.c.branch_id == workspace.branch_id,
  242. )
  243. )
  244. )
  245. .mappings()
  246. .one()
  247. )
  248. assert plan["status"] == "受阻"
  249. assert plan["result_note"] == "validator hard defect"
  250. with pytest.raises(WorkspaceError, match="ATTEMPT_WORKSPACE_FROZEN"):
  251. await repository.create_paragraph(
  252. context, paragraph_index=2, name="late", content_range={"topics": [2]}
  253. )
  254. @pytest.mark.asyncio
  255. async def test_element_and_link_batch_is_atomic_and_workspace_scoped(database) -> None:
  256. _, sessions = database
  257. artifacts = SqlAlchemyScriptBusinessArtifactRepository(sessions)
  258. repository = SqlAlchemyCandidateWorkspaceRepository(sessions, artifacts)
  259. paragraph_context = _context("base-paragraph")
  260. await repository.get_or_create(paragraph_context, artifact_kind=ArtifactKind.PARAGRAPH)
  261. paragraph_id = await repository.create_paragraph(
  262. paragraph_context,
  263. paragraph_index=1,
  264. name="body",
  265. content_range={"topics": [1]},
  266. )
  267. base, base_ref = await repository.freeze(paragraph_context, lineage=_lineage())
  268. element_context = _context("element-attempt", task="element-task")
  269. workspace = await repository.get_or_create(
  270. element_context,
  271. artifact_kind=ArtifactKind.ELEMENT_SET,
  272. base_artifact_ref=base_ref,
  273. )
  274. snapshot = await repository.snapshot(element_context)
  275. copied_paragraph = snapshot.paragraphs[0].paragraph_id
  276. assert copied_paragraph != paragraph_id
  277. assert snapshot.source_identity_map == (
  278. {
  279. "entity": "paragraph",
  280. "source_local_id": paragraph_id,
  281. "local_id": copied_paragraph,
  282. },
  283. )
  284. element_id = await repository.create_element(
  285. element_context,
  286. name="specific observation",
  287. dimension_primary="实质",
  288. dimension_secondary="detail",
  289. commonality_analysis={"shared": "grounded"},
  290. topic_support={"topic": 1},
  291. weight_score={"score": 0.8},
  292. support_elements=({"ref": "source-1"},),
  293. )
  294. with pytest.raises(WorkspaceError, match="LEGACY_REFERENCE_INVALID"):
  295. await repository.batch_link(
  296. element_context,
  297. [(copied_paragraph, [element_id]), (999999, [element_id])],
  298. )
  299. async with sessions() as session:
  300. assert (
  301. await session.scalar(select(func.count()).select_from(script_build_paragraph_element))
  302. ) == 0
  303. assert await repository.batch_link(element_context, [(copied_paragraph, [element_id])]) == 1
  304. with pytest.raises(WorkspaceError, match="STALE_BASE_REVISION"):
  305. await repository.freeze(
  306. element_context,
  307. lineage=_lineage(
  308. base_ref=base_ref.uri,
  309. base_digest="sha256:" + "f" * 64,
  310. base_revision=base.artifact_version_id,
  311. ),
  312. )
  313. version, _ = await repository.freeze(
  314. element_context,
  315. lineage=_lineage(
  316. base_ref=base_ref.uri,
  317. base_digest=base_ref.digest,
  318. base_revision=base.artifact_version_id,
  319. ),
  320. )
  321. assert version.artifact.lineage.source_lineage[0]["source_local_id"] == paragraph_id
  322. assert workspace.branch_id == version.artifact_version_id
  323. @pytest.mark.asyncio
  324. async def test_wrong_adapter_cross_context_and_stale_base_are_rejected(database) -> None:
  325. _, sessions = database
  326. artifacts = SqlAlchemyScriptBusinessArtifactRepository(sessions)
  327. repository = SqlAlchemyCandidateWorkspaceRepository(sessions, artifacts)
  328. context = _context("element-only")
  329. await repository.get_or_create(context, artifact_kind=ArtifactKind.ELEMENT_SET)
  330. with pytest.raises(WorkspaceError, match="WRITE_SCOPE_VIOLATION"):
  331. await repository.create_paragraph(
  332. context,
  333. paragraph_index=1,
  334. name="forbidden",
  335. content_range={"topics": [1]},
  336. )
  337. with pytest.raises(WorkspaceError, match="LEGACY_REFERENCE_INVALID"):
  338. await repository.require(_context("element-only", task="other-task"))
  339. with pytest.raises(WorkspaceError, match="STALE_BASE_REVISION"):
  340. await repository.freeze(
  341. context,
  342. lineage=CandidateLineageV1(
  343. scope_ref="script-build://scopes/opening",
  344. input_snapshot_ref="script-build://inputs/11",
  345. input_closure_digest="sha256:" + "a" * 64,
  346. write_scope=("script-build://scopes/opening",),
  347. base_artifact_ref="script-build://artifact-versions/999",
  348. base_artifact_digest="sha256:" + "b" * 64,
  349. base_revision=999,
  350. ),
  351. )
  352. async with sessions() as session:
  353. counts = {
  354. "paragraph": await session.scalar(
  355. select(func.count()).select_from(script_build_paragraph)
  356. ),
  357. "element": await session.scalar(select(func.count()).select_from(script_build_element)),
  358. }
  359. assert counts == {"paragraph": 0, "element": 0}
  360. @pytest.mark.asyncio
  361. async def test_concurrent_workspace_replay_converges_on_one_attempt_and_branch(database) -> None:
  362. _, sessions = database
  363. artifacts = SqlAlchemyScriptBusinessArtifactRepository(sessions)
  364. repository = SqlAlchemyCandidateWorkspaceRepository(sessions, artifacts)
  365. context = _context("attempt-concurrent")
  366. first, second = await asyncio.gather(
  367. repository.get_or_create(context, artifact_kind=ArtifactKind.PARAGRAPH),
  368. repository.get_or_create(context, artifact_kind=ArtifactKind.PARAGRAPH),
  369. )
  370. assert first == second
  371. assert first.branch_id == first.artifact_version_id > 0
  372. async with sessions() as session:
  373. rows = (
  374. (
  375. await session.execute(
  376. select(artifact_version_table).where(
  377. artifact_version_table.c.attempt_id == context.attempt_id
  378. )
  379. )
  380. )
  381. .mappings()
  382. .all()
  383. )
  384. assert len(rows) == 1
  385. assert rows[0]["legacy_branch_id"] == rows[0]["id"]
  386. @pytest.mark.asyncio
  387. async def test_batch_update_validates_every_paragraph_before_mutating(database) -> None:
  388. _, sessions = database
  389. artifacts = SqlAlchemyScriptBusinessArtifactRepository(sessions)
  390. repository = SqlAlchemyCandidateWorkspaceRepository(sessions, artifacts)
  391. context = _context("attempt-atomic-update")
  392. await repository.get_or_create(context, artifact_kind=ArtifactKind.PARAGRAPH)
  393. first_id = await repository.create_paragraph(
  394. context,
  395. paragraph_index=1,
  396. name="original",
  397. content_range={"topics": [1]},
  398. )
  399. second_id = await repository.create_paragraph(
  400. context,
  401. paragraph_index=2,
  402. name="second",
  403. content_range={"topics": [2]},
  404. )
  405. with pytest.raises(WorkspaceError, match="LEGACY_REFERENCE_INVALID"):
  406. await repository.batch_update_paragraphs(
  407. context,
  408. [
  409. {"paragraph_id": first_id, "name": "must roll back"},
  410. {"paragraph_id": 999_999, "name": "outside workspace"},
  411. ],
  412. )
  413. snapshot = await repository.snapshot(context)
  414. assert snapshot.paragraphs[0].name == "original"
  415. with pytest.raises(WorkspaceError, match="paragraph name"):
  416. await repository.batch_update_paragraphs(
  417. context,
  418. [
  419. {"paragraph_id": first_id, "description": "must still roll back"},
  420. {"paragraph_id": second_id, "name": ""},
  421. ],
  422. )
  423. snapshot = await repository.snapshot(context)
  424. assert snapshot.paragraphs[0].description is None
  425. assert snapshot.paragraphs[1].name == "second"
  426. @pytest.mark.asyncio
  427. async def test_element_patch_copies_complete_projection_and_freezes_change_manifest(
  428. database,
  429. ) -> None:
  430. _, sessions = database
  431. artifacts = SqlAlchemyScriptBusinessArtifactRepository(sessions)
  432. repository = SqlAlchemyCandidateWorkspaceRepository(sessions, artifacts)
  433. paragraph_context = _context("paragraph-base")
  434. await repository.get_or_create(paragraph_context, artifact_kind=ArtifactKind.PARAGRAPH)
  435. source_paragraph_id = await repository.create_paragraph(
  436. paragraph_context,
  437. paragraph_index=1,
  438. name="body",
  439. content_range={"topics": [1]},
  440. )
  441. paragraph_version, paragraph_ref = await repository.freeze(
  442. paragraph_context, lineage=_lineage()
  443. )
  444. element_context = _context("element-base", task="element-base-task")
  445. await repository.get_or_create(
  446. element_context,
  447. artifact_kind=ArtifactKind.ELEMENT_SET,
  448. base_artifact_ref=paragraph_ref,
  449. )
  450. copied_paragraph_id = (await repository.snapshot(element_context)).paragraphs[0].paragraph_id
  451. source_element_id = await repository.create_element(
  452. element_context,
  453. name="old detail",
  454. dimension_primary="实质",
  455. dimension_secondary="detail",
  456. )
  457. await repository.batch_link(element_context, [(copied_paragraph_id, [source_element_id])])
  458. element_version, element_ref = await repository.freeze(
  459. element_context,
  460. lineage=_lineage(
  461. base_ref=paragraph_ref.uri,
  462. base_digest=paragraph_ref.digest,
  463. base_revision=paragraph_version.artifact_version_id,
  464. ),
  465. )
  466. assert len(element_version.artifact.paragraphs) == 1
  467. assert len(element_version.artifact.paragraph_element_links) == 1
  468. replacement_context = _context("element-replacement", task="element-replacement-task")
  469. replacement = await repository.get_or_create(
  470. replacement_context,
  471. artifact_kind=ArtifactKind.ELEMENT_SET,
  472. base_artifact_ref=element_ref,
  473. )
  474. copied = await repository.snapshot(replacement_context)
  475. replacement_paragraph_id = copied.paragraphs[0].paragraph_id
  476. replacement_element_id = copied.elements[0].element_id
  477. assert replacement_paragraph_id not in {source_paragraph_id, copied_paragraph_id}
  478. assert replacement_element_id != source_element_id
  479. assert copied.links[0].paragraph_id == replacement_paragraph_id
  480. assert copied.links[0].element_id == replacement_element_id
  481. await repository.update_element(
  482. replacement_context,
  483. element_id=replacement_element_id,
  484. values={"name": "replaced detail", "is_active": False},
  485. )
  486. new_element_id = await repository.create_element(
  487. replacement_context,
  488. name="new detail",
  489. dimension_primary="实质",
  490. dimension_secondary="detail",
  491. )
  492. await repository.batch_link(replacement_context, [(replacement_paragraph_id, [new_element_id])])
  493. replacement_version, _ = await repository.freeze(
  494. replacement_context,
  495. lineage=_lineage(
  496. base_ref=element_ref.uri,
  497. base_digest=element_ref.digest,
  498. base_revision=element_version.artifact_version_id,
  499. ),
  500. )
  501. manifest = replacement_version.artifact.change_manifest
  502. assert manifest["created"]["element_ids"] == [new_element_id]
  503. assert manifest["created"]["links"] == [
  504. {"paragraph_id": replacement_paragraph_id, "element_id": new_element_id}
  505. ]
  506. assert manifest["updated"]["element_ids"] == [replacement_element_id]
  507. assert manifest["deactivated"]["element_ids"] == [replacement_element_id]
  508. assert manifest["deleted_links"] == [
  509. {"paragraph_id": copied_paragraph_id, "element_id": source_element_id}
  510. ]
  511. assert replacement.branch_id == replacement_version.artifact_version_id > 0
  512. for operation in (
  513. repository.create_element(
  514. replacement_context,
  515. name="late",
  516. dimension_primary="实质",
  517. dimension_secondary="detail",
  518. ),
  519. repository.update_element(
  520. replacement_context,
  521. element_id=new_element_id,
  522. values={"name": "late"},
  523. ),
  524. repository.batch_link(replacement_context, [(replacement_paragraph_id, [new_element_id])]),
  525. repository.delete_links(
  526. replacement_context,
  527. pairs=((replacement_paragraph_id, new_element_id),),
  528. ),
  529. ):
  530. with pytest.raises(WorkspaceError, match="ATTEMPT_WORKSPACE_FROZEN"):
  531. await operation
  532. @pytest.mark.asyncio
  533. async def test_run_454_shape_roundtrips_without_branch_zero(database) -> None:
  534. """The legacy 5/11/13 detail shape remains expressible without old Branch/Round rows."""
  535. _, sessions = database
  536. artifacts = SqlAlchemyScriptBusinessArtifactRepository(sessions)
  537. repository = SqlAlchemyCandidateWorkspaceRepository(sessions, artifacts)
  538. context = _context("run-454-shape", task="compose-454")
  539. workspace = await repository.get_or_create(
  540. context, artifact_kind=ArtifactKind.STRUCTURED_SCRIPT
  541. )
  542. paragraph_ids = [
  543. await repository.create_paragraph(
  544. context,
  545. paragraph_index=index,
  546. name=f"paragraph-{index}",
  547. content_range={"topics": [index]},
  548. )
  549. for index in range(1, 6)
  550. ]
  551. element_ids = [
  552. await repository.create_element(
  553. context,
  554. name=f"element-{index}",
  555. dimension_primary="实质" if index <= 6 else "形式",
  556. dimension_secondary=f"dimension-{index}",
  557. )
  558. for index in range(1, 12)
  559. ]
  560. link_pairs = [(paragraph_ids[index % 5], [element_ids[index % 11]]) for index in range(11)]
  561. link_pairs.extend(
  562. [
  563. (paragraph_ids[0], [element_ids[1]]),
  564. (paragraph_ids[1], [element_ids[2]]),
  565. ]
  566. )
  567. assert await repository.batch_link(context, link_pairs) == 13
  568. version, reference = await repository.freeze(
  569. context,
  570. lineage=_lineage(),
  571. structured_script={
  572. "direction_ref": "script-build://artifact-versions/999",
  573. "source_artifact_refs": ["script-build://artifact-versions/888"],
  574. "evidence_refs": ["script-build://artifact-versions/777"],
  575. "acceptance_notes": ["legacy detail shape preserved"],
  576. },
  577. )
  578. loaded = await artifacts.read_by_ref(reference, script_build_id=1)
  579. assert loaded == version
  580. assert len(loaded.artifact.paragraphs) == 5
  581. assert len(loaded.artifact.elements) == 11
  582. assert len(loaded.artifact.paragraph_element_links) == 13
  583. assert workspace.branch_id == version.artifact_version_id > 0
  584. async with sessions() as session:
  585. paragraph_branches = set(
  586. (
  587. await session.execute(
  588. select(script_build_paragraph.c.branch_id).where(
  589. script_build_paragraph.c.script_build_id == 1
  590. )
  591. )
  592. ).scalars()
  593. )
  594. element_branches = set(
  595. (
  596. await session.execute(
  597. select(script_build_element.c.branch_id).where(
  598. script_build_element.c.script_build_id == 1
  599. )
  600. )
  601. ).scalars()
  602. )
  603. link_branches = set(
  604. (
  605. await session.execute(
  606. select(script_build_paragraph_element.c.branch_id).where(
  607. script_build_paragraph_element.c.script_build_id == 1
  608. )
  609. )
  610. ).scalars()
  611. )
  612. assert paragraph_branches == element_branches == link_branches == {workspace.branch_id}