test_phase_two_workspace.py 26 KB

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