TipitakaRelatedParagraphV3Test.php 8.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220
  1. <?php
  2. use App\Models\BookTitle;
  3. use App\Models\PaliText;
  4. use App\Models\RelatedParagraph;
  5. use App\Models\Tag;
  6. use App\Models\TagMap;
  7. use Illuminate\Foundation\Testing\RefreshDatabase;
  8. use Illuminate\Support\Facades\DB;
  9. use Illuminate\Support\Str;
  10. uses(RefreshDatabase::class);
  11. /**
  12. * GET /v3/tipitaka-related-paragraphs 与 /aggregate —— 段落关联关系的查询与聚合。
  13. *
  14. * 取代 v2 的 related-paragraph:保留 book+para 锚点定位,新增 book_name/cs_para
  15. * 过滤器与按维度聚合;同时把旧 Resource 的逐行 N+1 收敛成批量查询。
  16. */
  17. function makeRelatedParagraph(int $book, int $para, int $bookId, int $csPara, string $bookName): RelatedParagraph
  18. {
  19. $row = new RelatedParagraph;
  20. $row->forceFill([
  21. 'book' => $book,
  22. 'para' => $para,
  23. 'book_id' => $bookId,
  24. 'cs_para' => $csPara,
  25. 'book_name' => $bookName,
  26. ])->save();
  27. return $row;
  28. }
  29. function makeBookTitle(int $sn, int $book, int $paragraph, string $title): BookTitle
  30. {
  31. $row = new BookTitle;
  32. $row->forceFill(['sn' => $sn, 'book' => $book, 'paragraph' => $paragraph, 'title' => $title])->save();
  33. return $row;
  34. }
  35. function makeRelatedPaliText(int $book, int $paragraph, array $attrs = []): PaliText
  36. {
  37. $row = new PaliText;
  38. $row->forceFill(array_merge([
  39. 'book' => $book,
  40. 'paragraph' => $paragraph,
  41. 'level' => 1,
  42. 'class' => '',
  43. 'toc' => '',
  44. 'text' => '',
  45. 'html' => '',
  46. 'uid' => (string) Str::uuid(),
  47. ], $attrs))->save();
  48. return $row;
  49. }
  50. function makeTag(string $name, int $color = 0): Tag
  51. {
  52. $tag = new Tag;
  53. $tag->forceFill(['id' => (string) Str::uuid(), 'name' => $name, 'color' => $color, 'owner_id' => (string) Str::uuid()])->save();
  54. return $tag;
  55. }
  56. it('lists related paragraphs grouped by book with title, tags and path', function () {
  57. makeRelatedParagraph(1, 2, 10, 4, 'dn1');
  58. makeRelatedParagraph(3, 5, 20, 4, 'dn1');
  59. makeBookTitle(10, 1, 100, 'dīghanikāyapāḷi');
  60. makeBookTitle(20, 3, 200, 'sumaṅgalavilāsinī');
  61. $root = makeRelatedPaliText(1, 100); // book_id=10 的书名行 → uid 供 tags
  62. $otherRoot = makeRelatedPaliText(3, 200); // book_id=20 的书名行
  63. makeRelatedPaliText(1, 2, ['path' => json_encode([['title' => 'path-a', 'level' => 1]])]);
  64. makeRelatedPaliText(3, 5, ['path' => json_encode([['title' => 'path-b', 'level' => 1]])]);
  65. $pali = makeTag('pāḷi', 1);
  66. $attha = makeTag('aṭṭhakathā', 2);
  67. (new TagMap)->forceFill(['id' => (string) Str::uuid(), 'table_name' => 'pali_texts', 'anchor_id' => $root->uid, 'tag_id' => $pali->id])->save();
  68. (new TagMap)->forceFill(['id' => (string) Str::uuid(), 'table_name' => 'pali_texts', 'anchor_id' => $otherRoot->uid, 'tag_id' => $attha->id])->save();
  69. $json = $this->getJson('/api/v3/tipitaka-related-paragraphs?book=1&para=2')->assertOk()->json();
  70. expect($json['data'])->toHaveCount(2)
  71. ->and($json['meta']['count'])->toBe(2)
  72. ->and($json['data'][0])->toMatchArray([
  73. 'book' => 1,
  74. 'book_id' => 10,
  75. 'title' => 'dīghanikāyapāḷi',
  76. 'cs_para' => 4,
  77. 'para' => [2],
  78. ])
  79. ->and($json['data'][0]['tags'])->toBe(['pāḷi'])
  80. ->and($json['data'][0]['path'][0]['title'])->toBe('path-a')
  81. ->and($json['data'][1]['title'])->toBe('sumaṅgalavilāsinī')
  82. ->and($json['data'][1]['tags'])->toBe(['aṭṭhakathā']);
  83. });
  84. it('filters directly by book_name and cs_para', function () {
  85. makeRelatedParagraph(1, 2, 10, 4, 'dn1');
  86. makeRelatedParagraph(3, 5, 20, 4, 'dn1');
  87. makeRelatedParagraph(3, 6, 20, 5, 'dn1'); // 不同 cs_para,不该命中
  88. $json = $this->getJson('/api/v3/tipitaka-related-paragraphs?book_name=dn1&cs_para=4')->assertOk()->json();
  89. expect($json['data'])->toHaveCount(2)
  90. ->and($json['meta']['count'])->toBe(2);
  91. });
  92. it('returns an empty set when the paragraph has no anchor', function () {
  93. makeRelatedParagraph(1, 2, 10, 0, 'dn1'); // cs_para=0:无锚点(书前页)
  94. $json = $this->getJson('/api/v3/tipitaka-related-paragraphs?book=1&para=2')->assertOk()->json();
  95. expect($json['data'])->toBe([])
  96. ->and($json['meta']['count'])->toBe(0);
  97. });
  98. it('lists distinct book names', function () {
  99. makeRelatedParagraph(100, 2, 110, 4, 'an2');
  100. makeRelatedParagraph(100, 3, 110, 4, 'an3');
  101. makeRelatedParagraph(101, 2, 111, 5, 'an2');
  102. $json = $this->getJson('/api/v3/tipitaka-related-paragraphs/aggregate')->assertOk()->json();
  103. expect($json['data'])->toBe([['book_name' => 'an2'], ['book_name' => 'an3']])
  104. ->and($json['meta']['total'])->toBe(2);
  105. });
  106. it('lists cs_para of a book name', function () {
  107. makeRelatedParagraph(100, 2, 110, 4, 'an2');
  108. makeRelatedParagraph(100, 3, 110, 5, 'an2');
  109. makeRelatedParagraph(101, 2, 111, 4, 'an2');
  110. makeRelatedParagraph(101, 3, 111, 5, 'an3');
  111. $json = $this->getJson('/api/v3/tipitaka-related-paragraphs/aggregate/an2')->assertOk()->json();
  112. expect($json['data'])->toBe([['cs_para' => '4'], ['cs_para' => '5']])
  113. ->and($json['meta']['total'])->toBe(2);
  114. });
  115. it('filters aggregate book names by file and book', function () {
  116. makeRelatedParagraph(100, 2, 110, 4, 'an2');
  117. makeRelatedParagraph(100, 3, 110, 5, 'an3');
  118. makeRelatedParagraph(101, 2, 111, 4, 'an2');
  119. // file 过滤 book 列:只剩 book=100 的两条
  120. $json = $this->getJson('/api/v3/tipitaka-related-paragraphs/aggregate?file=100')->assertOk()->json();
  121. expect($json['data'])->toBe([['book_name' => 'an2'], ['book_name' => 'an3']]);
  122. // book 过滤 book_id 列:只剩 book_id=110 的两条
  123. $json = $this->getJson('/api/v3/tipitaka-related-paragraphs/aggregate?book=110')->assertOk()->json();
  124. expect($json['data'])->toBe([['book_name' => 'an2'], ['book_name' => 'an3']]);
  125. // book 过滤 book_id 列:只剩 book_id=111 的一条
  126. $json = $this->getJson('/api/v3/tipitaka-related-paragraphs/aggregate?book=111')->assertOk()->json();
  127. expect($json['data'])->toBe([['book_name' => 'an2']]);
  128. });
  129. it('filters aggregate cs_para by file and book', function () {
  130. makeRelatedParagraph(100, 2, 110, 4, 'an2');
  131. makeRelatedParagraph(100, 3, 110, 5, 'an2');
  132. makeRelatedParagraph(101, 2, 111, 4, 'an2');
  133. // file 过滤 book 列:只剩 book=100 的两条
  134. $json = $this->getJson('/api/v3/tipitaka-related-paragraphs/aggregate/an2?file=100')->assertOk()->json();
  135. expect($json['data'])->toBe([['cs_para' => '4'], ['cs_para' => '5']]);
  136. // book 过滤 book_id 列:只剩 book_id=111 的一条
  137. $json = $this->getJson('/api/v3/tipitaka-related-paragraphs/aggregate/an2?book=111')->assertOk()->json();
  138. expect($json['data'])->toBe([['cs_para' => '4']]);
  139. });
  140. it('validates the mutually exclusive lookup filters', function () {
  141. $this->getJson('/api/v3/tipitaka-related-paragraphs?book=1')
  142. ->assertStatus(422)
  143. ->assertJsonStructure(['errors' => ['para']]);
  144. $this->getJson('/api/v3/tipitaka-related-paragraphs?book=1&para=2&book_name=dn1')
  145. ->assertStatus(422);
  146. $this->getJson('/api/v3/tipitaka-related-paragraphs')
  147. ->assertStatus(422);
  148. });
  149. it('validates the aggregate endpoint pagination', function () {
  150. $this->getJson('/api/v3/tipitaka-related-paragraphs/aggregate?per_page=100000')
  151. ->assertStatus(422)
  152. ->assertJsonStructure(['errors' => ['per_page']]);
  153. $this->getJson('/api/v3/tipitaka-related-paragraphs/aggregate/an2?per_page=100000')
  154. ->assertStatus(422)
  155. ->assertJsonStructure(['errors' => ['per_page']]);
  156. });
  157. it('issues a constant number of queries regardless of book count', function () {
  158. makeRelatedParagraph(1, 1, 10, 7, 'dn1');
  159. makeRelatedParagraph(2, 1, 20, 7, 'dn1');
  160. makeRelatedParagraph(3, 1, 30, 7, 'dn1');
  161. makeBookTitle(10, 1, 100, 'a');
  162. makeBookTitle(20, 2, 100, 'b');
  163. makeBookTitle(30, 3, 100, 'c');
  164. DB::flushQueryLog();
  165. DB::enableQueryLog();
  166. $this->getJson('/api/v3/tipitaka-related-paragraphs?book=1&para=1')->assertOk();
  167. $queries = count(DB::getQueryLog());
  168. DB::disableQueryLog();
  169. // 再加一本:旧实现会每本多打 5 条 SQL,这里查询数必须保持常量
  170. makeRelatedParagraph(4, 1, 40, 7, 'dn1');
  171. makeBookTitle(40, 4, 100, 'd');
  172. DB::flushQueryLog();
  173. DB::enableQueryLog();
  174. $this->getJson('/api/v3/tipitaka-related-paragraphs?book=1&para=1')->assertOk();
  175. expect(count(DB::getQueryLog()))->toBe($queries);
  176. DB::disableQueryLog();
  177. });