TipitakaReadingChunkTest.php 9.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234
  1. <?php
  2. use App\Models\PaliText;
  3. use Illuminate\Foundation\Testing\RefreshDatabase;
  4. use Illuminate\Support\Str;
  5. uses(RefreshDatabase::class);
  6. /**
  7. * 建一个 pali_texts 段落。第一段带 chapter_len 表示章节长度
  8. */
  9. function makePaliText(int $book, int $para, int $lenght, ?int $chapterLen = null): void
  10. {
  11. (new PaliText)->forceFill([
  12. 'book' => $book,
  13. 'paragraph' => $para,
  14. 'level' => $chapterLen ? 2 : 9,
  15. 'class' => '',
  16. 'toc' => '',
  17. 'text' => '',
  18. 'html' => '',
  19. 'lenght' => $lenght,
  20. 'chapter_len' => $chapterLen,
  21. 'pcd_book_id' => 0,
  22. 'uid' => (string) Str::uuid(),
  23. ])->save();
  24. }
  25. /**
  26. * book 9002 一个 5 段的章节(起始段落号 1),每段 100 字节。$translated 列出
  27. * 哪些段落有译文,默认整章都有;传子集即模拟残缺译文。
  28. *
  29. * @param array<int, int> $translated
  30. */
  31. function makeChapterFixture(array $translated = [1, 2, 3, 4, 5]): string
  32. {
  33. $channel = makeChannel(makeStudio('chapter-owner'), 'chapter channel');
  34. makePaliText(9002, 1, 100, 5);
  35. foreach (range(2, 5) as $para) {
  36. makePaliText(9002, $para, 100);
  37. }
  38. foreach ($translated as $para) {
  39. makeSentence($channel, 9002, $para, 1, "para {$para} text");
  40. }
  41. return $channel;
  42. }
  43. it('fetches a block from the start and follows next_cursor', function () {
  44. $channel = makeChapterFixture();
  45. $base = "/api/v3/tipitaka-reading/{$channel}?book=9002&chapter=1&page_size=2";
  46. // 不带 after,从章节开头取起
  47. $first = $this->getJson($base)->assertOk()->json();
  48. expect(array_column($first['data'], 'para'))->toBe([1, 2])
  49. ->and($first['meta'])->toMatchArray([
  50. 'page_size' => 2,
  51. 'page_size_unit' => 'para',
  52. 'total' => 5,
  53. 'remaining' => 3,
  54. 'next_cursor' => '9002-2',
  55. ]);
  56. // 断点续传:把 next_cursor 原样传回 after
  57. $second = $this->getJson($base.'&after='.$first['meta']['next_cursor'])->assertOk()->json();
  58. expect(array_column($second['data'], 'para'))->toBe([3, 4])
  59. ->and($second['meta'])->toMatchArray(['remaining' => 1, 'next_cursor' => '9002-4']);
  60. // 最后一块:next_cursor 为 null 即取完
  61. $last = $this->getJson($base.'&after='.$second['meta']['next_cursor'])->assertOk()->json();
  62. expect(array_column($last['data'], 'para'))->toBe([5])
  63. ->and($last['meta']['remaining'])->toBe(0)
  64. ->and($last['meta']['next_cursor'])->toBeNull();
  65. });
  66. it('returns an empty block when the cursor is past the end', function () {
  67. $channel = makeChapterFixture();
  68. // keyset 语义:游标在范围之后就是空结果,不是错误。游标由服务端给出,
  69. // 客户端不自己拼,所以不必再校验它落在哪个区间
  70. $json = $this->getJson("/api/v3/tipitaka-reading/{$channel}?book=9002&chapter=1&after=9002-5")
  71. ->assertOk()
  72. ->json();
  73. expect($json['data'])->toBe([])
  74. ->and($json['meta']['next_cursor'])->toBeNull()
  75. ->and($json['meta']['remaining'])->toBe(0);
  76. });
  77. it('scopes the chapter by chapter_len', function () {
  78. $channel = makeChapterFixture();
  79. // para=3 那一行没有 chapter_len,区间只有它自己
  80. expect($this->getJson("/api/v3/tipitaka-reading/{$channel}?book=9002&chapter=3")
  81. ->assertOk()->json('meta'))
  82. ->toMatchArray(['total' => 1, 'remaining' => 0, 'next_cursor' => null]);
  83. });
  84. it('skips paragraphs that have no translation', function () {
  85. // 只译了 1、3、5;游标停在第 1 段之后,下一块直接跳到 3 和 5
  86. $channel = makeChapterFixture([1, 3, 5]);
  87. $block = $this->getJson("/api/v3/tipitaka-reading/{$channel}?book=9002&chapter=1&page_size=2&after=9002-1")
  88. ->assertOk()
  89. ->json();
  90. expect(array_column($block['data'], 'para'))->toBe([3, 5])
  91. ->and($block['meta'])->toMatchArray([
  92. 'total' => 3,
  93. 'remaining' => 0,
  94. 'next_cursor' => null,
  95. ]);
  96. });
  97. it('counts only the paragraphs this channel has actually translated', function () {
  98. // 5 段的章节里只译了 1、3、5,分块按这 3 段算,不会切出空块
  99. $channel = makeChapterFixture([1, 3, 5]);
  100. $base = "/api/v3/tipitaka-reading/{$channel}?book=9002&chapter=1&page_size=2";
  101. $first = $this->getJson($base)->assertOk()->json();
  102. expect(array_column($first['data'], 'para'))->toBe([1, 3])
  103. ->and($first['meta'])->toMatchArray(['total' => 3, 'remaining' => 1, 'next_cursor' => '9002-3']);
  104. $second = $this->getJson($base.'&after=9002-3')->assertOk()->json();
  105. expect(array_column($second['data'], 'para'))->toBe([5])
  106. ->and($second['meta']['remaining'])->toBe(0);
  107. });
  108. it('fetches a block by byte size using the lenght column', function () {
  109. $channel = makeChapterFixture();
  110. // 每段 100 字节,250 字节累加到第 3 段才超过上限
  111. $base = "/api/v3/tipitaka-reading/{$channel}?book=9002&chapter=1&page_size=250&unit=byte";
  112. $first = $this->getJson($base)->assertOk()->json();
  113. expect(array_column($first['data'], 'para'))->toBe([1, 2, 3])
  114. ->and($first['meta'])->toMatchArray([
  115. 'page_size' => 250,
  116. 'page_size_unit' => 'byte',
  117. 'remaining' => 2,
  118. ]);
  119. $second = $this->getJson($base.'&after='.$first['meta']['next_cursor'])->assertOk()->json();
  120. expect(array_column($second['data'], 'para'))->toBe([4, 5])
  121. ->and($second['meta']['remaining'])->toBe(0);
  122. });
  123. it('counts bytes from pali_texts for the translated paragraphs only', function () {
  124. // 只译了 1、3、5;每段原文 100 字节,250b 累加到第三段(para 5)才超限
  125. $channel = makeChapterFixture([1, 3, 5]);
  126. $first = $this->getJson("/api/v3/tipitaka-reading/{$channel}?book=9002&chapter=1&page_size=250&unit=byte")
  127. ->assertOk()
  128. ->json();
  129. expect(array_column($first['data'], 'para'))->toBe([1, 3, 5])
  130. ->and($first['meta']['remaining'])->toBe(0);
  131. });
  132. it('returns at least one paragraph when it alone exceeds the byte size', function () {
  133. $channel = makeChapterFixture();
  134. $items = $this->getJson("/api/v3/tipitaka-reading/{$channel}?book=9002&chapter=1&page_size=1&unit=byte")
  135. ->assertOk()
  136. ->json('data');
  137. expect(array_column($items, 'para'))->toBe([1]);
  138. });
  139. it('returns an empty collection when the channel has nothing translated here', function () {
  140. $channel = makeChapterFixture([]);
  141. // 集合端点,没内容就是空集合;章节本身存在,不是 404
  142. $json = $this->getJson("/api/v3/tipitaka-reading/{$channel}?book=9002&chapter=1")
  143. ->assertOk()
  144. ->json();
  145. expect($json['data'])->toBe([])
  146. ->and($json['meta'])->toMatchArray(['total' => 0, 'remaining' => 0, 'next_cursor' => null]);
  147. });
  148. it('clamps page_size to the per-block ceiling and reports the effective value', function () {
  149. $channel = makeChapterFixture();
  150. $base = "/api/v3/tipitaka-reading/{$channel}?book=9002&chapter=1";
  151. // 段落模式上限 200
  152. expect($this->getJson($base.'&page_size=1000')->assertOk()->json('meta'))
  153. ->toMatchArray(['page_size' => 200, 'page_size_unit' => 'para']);
  154. // 字节模式上限 5000
  155. expect($this->getJson($base.'&page_size=20000&unit=byte')->assertOk()->json('meta'))
  156. ->toMatchArray(['page_size' => 5000, 'page_size_unit' => 'byte']);
  157. // 没到上限的照常返回原值
  158. expect($this->getJson($base.'&page_size=3')->assertOk()->json('meta.page_size'))->toBe(3);
  159. });
  160. it('accepts the cursor together with the include parameter', function () {
  161. $channel = makeChapterFixture();
  162. $block = $this->getJson("/api/v3/tipitaka-reading/{$channel}?book=9002&chapter=1&page_size=1&after=9002-1&include=all")
  163. ->assertOk()
  164. ->json();
  165. expect($block['data'])->toHaveCount(1)
  166. ->and($block['data'][0])->toHaveKeys(['book', 'para', 'display', 'sentences'])
  167. ->and($block['data'][0]['para'])->toBe(2);
  168. });
  169. it('rejects bad filters and an unknown chapter', function () {
  170. $channel = makeChapterFixture();
  171. // 路径上只有 channel,格式不对匹配不上路由 → 404
  172. $this->getJson('/api/v3/tipitaka-reading/not-a-uuid')->assertStatus(404);
  173. // chapter 在 pali_texts 里不存在 → 404
  174. $this->getJson("/api/v3/tipitaka-reading/{$channel}?book=9002&chapter=99")
  175. ->assertStatus(404)
  176. ->assertJsonPath('title', 'Resource not found.');
  177. // 其余都是 filter,走 FormRequest → 422
  178. $this->getJson("/api/v3/tipitaka-reading/{$channel}?book=9002&chapter=1&page_size=0")
  179. ->assertStatus(422)
  180. ->assertJsonStructure(['errors' => ['page_size']]);
  181. $this->getJson("/api/v3/tipitaka-reading/{$channel}?book=9002&chapter=1&page_size=10p")
  182. ->assertStatus(422)
  183. ->assertJsonStructure(['errors' => ['page_size']]);
  184. $this->getJson("/api/v3/tipitaka-reading/{$channel}?book=9002&chapter=1&unit=bytes")
  185. ->assertStatus(422)
  186. ->assertJsonStructure(['errors' => ['unit']]);
  187. // 游标格式不对
  188. $this->getJson("/api/v3/tipitaka-reading/{$channel}?book=9002&chapter=1&after=not-a-cursor")
  189. ->assertStatus(422)
  190. ->assertJsonStructure(['errors' => ['after']]);
  191. });