test_phase_two_workspace.py 26 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673
  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 = replace(
  331. _context("element-only"),
  332. write_scope=("script-build://writes/elements",),
  333. )
  334. await repository.get_or_create(context, artifact_kind=ArtifactKind.ELEMENT_SET)
  335. with pytest.raises(WorkspaceError, match="WRITE_SCOPE_VIOLATION"):
  336. await repository.create_paragraph(
  337. context,
  338. paragraph_index=1,
  339. name="forbidden",
  340. content_range={"topics": [1]},
  341. )
  342. with pytest.raises(WorkspaceError, match="LEGACY_REFERENCE_INVALID"):
  343. await repository.require(_context("element-only", task="other-task"))
  344. with pytest.raises(WorkspaceError, match="STALE_BASE_REVISION"):
  345. await repository.freeze(
  346. context,
  347. lineage=CandidateLineageV1(
  348. scope_ref="script-build://scopes/opening",
  349. input_snapshot_ref="script-build://inputs/11",
  350. input_closure_digest="sha256:" + "a" * 64,
  351. write_scope=("script-build://scopes/opening",),
  352. goal_ids=("goal-1",),
  353. base_artifact_ref="script-build://artifact-versions/999",
  354. base_artifact_digest="sha256:" + "b" * 64,
  355. base_revision=999,
  356. ),
  357. )
  358. async with sessions() as session:
  359. counts = {
  360. "paragraph": await session.scalar(
  361. select(func.count()).select_from(script_build_paragraph)
  362. ),
  363. "element": await session.scalar(select(func.count()).select_from(script_build_element)),
  364. }
  365. assert counts == {"paragraph": 0, "element": 0}
  366. @pytest.mark.asyncio
  367. async def test_concurrent_workspace_replay_converges_on_one_attempt_and_branch(database) -> None:
  368. _, sessions = database
  369. artifacts = SqlAlchemyScriptBusinessArtifactRepository(sessions)
  370. repository = SqlAlchemyCandidateWorkspaceRepository(sessions, artifacts)
  371. context = _context("attempt-concurrent")
  372. first, second = await asyncio.gather(
  373. repository.get_or_create(context, artifact_kind=ArtifactKind.PARAGRAPH),
  374. repository.get_or_create(context, artifact_kind=ArtifactKind.PARAGRAPH),
  375. )
  376. assert first == second
  377. assert first.branch_id == first.artifact_version_id > 0
  378. async with sessions() as session:
  379. rows = (
  380. (
  381. await session.execute(
  382. select(artifact_version_table).where(
  383. artifact_version_table.c.attempt_id == context.attempt_id
  384. )
  385. )
  386. )
  387. .mappings()
  388. .all()
  389. )
  390. assert len(rows) == 1
  391. assert rows[0]["legacy_branch_id"] == rows[0]["id"]
  392. @pytest.mark.asyncio
  393. async def test_batch_update_validates_every_paragraph_before_mutating(database) -> None:
  394. _, sessions = database
  395. artifacts = SqlAlchemyScriptBusinessArtifactRepository(sessions)
  396. repository = SqlAlchemyCandidateWorkspaceRepository(sessions, artifacts)
  397. context = _context("attempt-atomic-update")
  398. await repository.get_or_create(context, artifact_kind=ArtifactKind.PARAGRAPH)
  399. first_id = await repository.create_paragraph(
  400. context,
  401. paragraph_index=1,
  402. name="original",
  403. content_range={"topics": [1]},
  404. )
  405. second_id = await repository.create_paragraph(
  406. context,
  407. paragraph_index=2,
  408. name="second",
  409. content_range={"topics": [2]},
  410. )
  411. with pytest.raises(WorkspaceError, match="LEGACY_REFERENCE_INVALID"):
  412. await repository.batch_update_paragraphs(
  413. context,
  414. [
  415. {"paragraph_id": first_id, "name": "must roll back"},
  416. {"paragraph_id": 999_999, "name": "outside workspace"},
  417. ],
  418. )
  419. snapshot = await repository.snapshot(context)
  420. assert snapshot.paragraphs[0].name == "original"
  421. with pytest.raises(WorkspaceError, match="paragraph name"):
  422. await repository.batch_update_paragraphs(
  423. context,
  424. [
  425. {"paragraph_id": first_id, "description": "must still roll back"},
  426. {"paragraph_id": second_id, "name": ""},
  427. ],
  428. )
  429. snapshot = await repository.snapshot(context)
  430. assert snapshot.paragraphs[0].description is None
  431. assert snapshot.paragraphs[1].name == "second"
  432. @pytest.mark.asyncio
  433. async def test_element_patch_copies_complete_projection_and_freezes_change_manifest(
  434. database,
  435. ) -> None:
  436. _, sessions = database
  437. artifacts = SqlAlchemyScriptBusinessArtifactRepository(sessions)
  438. repository = SqlAlchemyCandidateWorkspaceRepository(sessions, artifacts)
  439. paragraph_context = _context("paragraph-base")
  440. await repository.get_or_create(paragraph_context, artifact_kind=ArtifactKind.PARAGRAPH)
  441. source_paragraph_id = await repository.create_paragraph(
  442. paragraph_context,
  443. paragraph_index=1,
  444. name="body",
  445. content_range={"topics": [1]},
  446. )
  447. paragraph_version, paragraph_ref = await repository.freeze(
  448. paragraph_context, lineage=_lineage()
  449. )
  450. element_context = _context("element-base", task="element-base-task")
  451. await repository.get_or_create(
  452. element_context,
  453. artifact_kind=ArtifactKind.ELEMENT_SET,
  454. base_artifact_ref=paragraph_ref,
  455. )
  456. copied_paragraph_id = (await repository.snapshot(element_context)).paragraphs[0].paragraph_id
  457. source_element_id = await repository.create_element(
  458. element_context,
  459. name="old detail",
  460. dimension_primary="实质",
  461. dimension_secondary="detail",
  462. )
  463. await repository.batch_link(element_context, [(copied_paragraph_id, [source_element_id])])
  464. element_version, element_ref = await repository.freeze(
  465. element_context,
  466. lineage=_lineage(
  467. base_ref=paragraph_ref.uri,
  468. base_digest=paragraph_ref.digest,
  469. base_revision=paragraph_version.artifact_version_id,
  470. ),
  471. )
  472. assert len(element_version.artifact.paragraphs) == 1
  473. assert len(element_version.artifact.paragraph_element_links) == 1
  474. replacement_context = _context("element-replacement", task="element-replacement-task")
  475. replacement = await repository.get_or_create(
  476. replacement_context,
  477. artifact_kind=ArtifactKind.ELEMENT_SET,
  478. base_artifact_ref=element_ref,
  479. )
  480. copied = await repository.snapshot(replacement_context)
  481. replacement_paragraph_id = copied.paragraphs[0].paragraph_id
  482. replacement_element_id = copied.elements[0].element_id
  483. assert replacement_paragraph_id not in {source_paragraph_id, copied_paragraph_id}
  484. assert replacement_element_id != source_element_id
  485. assert copied.links[0].paragraph_id == replacement_paragraph_id
  486. assert copied.links[0].element_id == replacement_element_id
  487. await repository.update_element(
  488. replacement_context,
  489. element_id=replacement_element_id,
  490. values={"name": "replaced detail", "is_active": False},
  491. )
  492. new_element_id = await repository.create_element(
  493. replacement_context,
  494. name="new detail",
  495. dimension_primary="实质",
  496. dimension_secondary="detail",
  497. )
  498. await repository.batch_link(replacement_context, [(replacement_paragraph_id, [new_element_id])])
  499. replacement_version, _ = await repository.freeze(
  500. replacement_context,
  501. lineage=_lineage(
  502. base_ref=element_ref.uri,
  503. base_digest=element_ref.digest,
  504. base_revision=element_version.artifact_version_id,
  505. ),
  506. )
  507. manifest = replacement_version.artifact.change_manifest
  508. assert manifest["created"]["element_ids"] == [new_element_id]
  509. assert manifest["created"]["links"] == [
  510. {"paragraph_id": replacement_paragraph_id, "element_id": new_element_id}
  511. ]
  512. assert manifest["updated"]["element_ids"] == [replacement_element_id]
  513. assert manifest["deactivated"]["element_ids"] == [replacement_element_id]
  514. assert manifest["deleted_links"] == [
  515. {"paragraph_id": copied_paragraph_id, "element_id": source_element_id}
  516. ]
  517. assert replacement.branch_id == replacement_version.artifact_version_id > 0
  518. for operation in (
  519. repository.create_element(
  520. replacement_context,
  521. name="late",
  522. dimension_primary="实质",
  523. dimension_secondary="detail",
  524. ),
  525. repository.update_element(
  526. replacement_context,
  527. element_id=new_element_id,
  528. values={"name": "late"},
  529. ),
  530. repository.batch_link(replacement_context, [(replacement_paragraph_id, [new_element_id])]),
  531. repository.delete_links(
  532. replacement_context,
  533. pairs=((replacement_paragraph_id, new_element_id),),
  534. ),
  535. ):
  536. with pytest.raises(WorkspaceError, match="ATTEMPT_WORKSPACE_FROZEN"):
  537. await operation
  538. @pytest.mark.asyncio
  539. async def test_run_454_shape_roundtrips_without_branch_zero(database) -> None:
  540. """The legacy 5/11/13 detail shape remains expressible without old Branch/Round rows."""
  541. _, sessions = database
  542. artifacts = SqlAlchemyScriptBusinessArtifactRepository(sessions)
  543. repository = SqlAlchemyCandidateWorkspaceRepository(sessions, artifacts)
  544. context = _context("run-454-shape", task="compose-454")
  545. workspace = await repository.get_or_create(
  546. context, artifact_kind=ArtifactKind.STRUCTURED_SCRIPT
  547. )
  548. paragraph_ids = [
  549. await repository.create_paragraph(
  550. context,
  551. paragraph_index=index,
  552. name=f"paragraph-{index}",
  553. content_range={"topics": [index]},
  554. )
  555. for index in range(1, 6)
  556. ]
  557. element_ids = [
  558. await repository.create_element(
  559. context,
  560. name=f"element-{index}",
  561. dimension_primary="实质" if index <= 6 else "形式",
  562. dimension_secondary=f"dimension-{index}",
  563. )
  564. for index in range(1, 12)
  565. ]
  566. link_pairs = [(paragraph_ids[index % 5], [element_ids[index % 11]]) for index in range(11)]
  567. link_pairs.extend(
  568. [
  569. (paragraph_ids[0], [element_ids[1]]),
  570. (paragraph_ids[1], [element_ids[2]]),
  571. ]
  572. )
  573. assert await repository.batch_link(context, link_pairs) == 13
  574. version, reference = await repository.freeze(
  575. context,
  576. lineage=_lineage(),
  577. structured_script={
  578. "direction_ref": "script-build://artifact-versions/999",
  579. "source_artifact_refs": ["script-build://artifact-versions/888"],
  580. "goal_coverage": [
  581. {
  582. "goal_id": "goal-1",
  583. "source_artifact_refs": ["script-build://artifact-versions/888"],
  584. }
  585. ],
  586. "evidence_refs": ["script-build://artifact-versions/777"],
  587. "acceptance_notes": ["legacy detail shape preserved"],
  588. },
  589. )
  590. loaded = await artifacts.read_by_ref(reference, script_build_id=1)
  591. assert loaded == version
  592. assert len(loaded.artifact.paragraphs) == 5
  593. assert len(loaded.artifact.elements) == 11
  594. assert len(loaded.artifact.paragraph_element_links) == 13
  595. assert workspace.branch_id == version.artifact_version_id > 0
  596. async with sessions() as session:
  597. paragraph_branches = set(
  598. (
  599. await session.execute(
  600. select(script_build_paragraph.c.branch_id).where(
  601. script_build_paragraph.c.script_build_id == 1
  602. )
  603. )
  604. ).scalars()
  605. )
  606. element_branches = set(
  607. (
  608. await session.execute(
  609. select(script_build_element.c.branch_id).where(
  610. script_build_element.c.script_build_id == 1
  611. )
  612. )
  613. ).scalars()
  614. )
  615. link_branches = set(
  616. (
  617. await session.execute(
  618. select(script_build_paragraph_element.c.branch_id).where(
  619. script_build_paragraph_element.c.script_build_id == 1
  620. )
  621. )
  622. ).scalars()
  623. )
  624. assert paragraph_branches == element_branches == link_branches == {workspace.branch_id}