TipitakaReadChapterTest.php 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251
  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 of paragraphs from the cursor, the default unit', function () {
  44. $channel = makeChapterFixture();
  45. $base = "/api/v3/tipitaka-read-chapter?book=9002&para=1&channel={$channel}&pagesize=2";
  46. // 不传 from,游标默认落在章节开头
  47. $first = $this->getJson($base)->assertOk()->json();
  48. expect(array_column($first['data'], 'para'))->toBe([1, 2]);
  49. expect($first['meta'])->toMatchArray([
  50. 'current_para' => 1,
  51. 'total_para' => 5,
  52. 'page_size' => 2,
  53. 'page_size_unit' => 'para',
  54. 'book' => 9002,
  55. 'chapter' => 1,
  56. 'first_para' => 1,
  57. 'last_para' => 2,
  58. 'remaining_para' => 3,
  59. ]);
  60. // 断点续传:把上次的 last_para + 1 当作下次的 from
  61. $second = $this->getJson($base.'&from=3')->assertOk()->json();
  62. expect(array_column($second['data'], 'para'))->toBe([3, 4]);
  63. expect($second['meta'])->toMatchArray(['current_para' => 3, 'remaining_para' => 1]);
  64. $last = $this->getJson($base.'&from=5')->assertOk()->json();
  65. expect(array_column($last['data'], 'para'))->toBe([5]);
  66. expect($last['meta']['remaining_para'])->toBe(0);
  67. // 游标越过章节区间(本章节是 1..5)
  68. $this->getJson($base.'&from=6')
  69. ->assertStatus(422)
  70. ->assertJsonPath('errors.from.0', __('site.invalid_parameter'));
  71. });
  72. it('rejects a cursor outside the chapter range', function () {
  73. $channel = makeChapterFixture();
  74. // 章节区间是 1..5,超出两端都不行
  75. $this->getJson("/api/v3/tipitaka-read-chapter?book=9002&para=1&channel={$channel}&from=99")
  76. ->assertStatus(422)
  77. ->assertJsonPath('errors.from.0', __('site.invalid_parameter'));
  78. // para=3 那一行没有 chapter_len,区间只有它自己;从第 1 段起就在区间之前
  79. $this->getJson("/api/v3/tipitaka-read-chapter?book=9002&para=3&channel={$channel}&from=1")
  80. ->assertStatus(422)
  81. ->assertJsonPath('errors.from.0', __('site.invalid_parameter'));
  82. // 落在区间内就正常取
  83. expect($this->getJson("/api/v3/tipitaka-read-chapter?book=9002&para=3&channel={$channel}&from=3")
  84. ->assertOk()->json('meta'))
  85. ->toMatchArray(['chapter' => 3, 'total_para' => 1, 'first_para' => 3, 'remaining_para' => 0]);
  86. });
  87. it('rejects a cursor that is inside the chapter but past the last translation', function () {
  88. // 章节区间 1..5,只译了 1、2、3;游标 5 在区间内但之后没有译文了
  89. $channel = makeChapterFixture([1, 2, 3]);
  90. $this->getJson("/api/v3/tipitaka-read-chapter?book=9002&para=1&channel={$channel}&from=5")
  91. ->assertStatus(422)
  92. ->assertJsonPath('errors.from.0', __('site.invalid_parameter'));
  93. });
  94. it('advances the cursor to the next translated paragraph', function () {
  95. // 只译了 1、3、5;游标落在没有译文的第 2 段,顺延到第 3 段
  96. $channel = makeChapterFixture([1, 3, 5]);
  97. $block = $this->getJson("/api/v3/tipitaka-read-chapter?book=9002&para=1&channel={$channel}&pagesize=2&from=2")
  98. ->assertOk()
  99. ->json();
  100. expect(array_column($block['data'], 'para'))->toBe([3, 5]);
  101. expect($block['meta'])->toMatchArray([
  102. 'current_para' => 2, // 请求的游标原样回显
  103. 'first_para' => 3, // 实际取到的第一段
  104. 'last_para' => 5,
  105. 'total_para' => 3,
  106. 'remaining_para' => 0,
  107. ]);
  108. });
  109. it('counts only the paragraphs this channel has actually translated', function () {
  110. // 5 段的章节里只译了 1、3、5,分块按这 3 段算,不会切出空块
  111. $channel = makeChapterFixture([1, 3, 5]);
  112. $base = "/api/v3/tipitaka-read-chapter?book=9002&para=1&channel={$channel}&pagesize=2";
  113. $first = $this->getJson($base)->assertOk()->json();
  114. expect(array_column($first['data'], 'para'))->toBe([1, 3]);
  115. expect($first['meta'])->toMatchArray([
  116. 'total_para' => 3,
  117. 'first_para' => 1,
  118. 'last_para' => 3,
  119. 'remaining_para' => 1,
  120. ]);
  121. $second = $this->getJson($base.'&from=4')->assertOk()->json();
  122. expect(array_column($second['data'], 'para'))->toBe([5]);
  123. expect($second['meta']['remaining_para'])->toBe(0);
  124. });
  125. it('fetches a block by byte size using the lenght column', function () {
  126. $channel = makeChapterFixture();
  127. // 每段 100 字节,250 字节累加到第 3 段才超过上限
  128. $base = "/api/v3/tipitaka-read-chapter?book=9002&para=1&channel={$channel}&pagesize=250&unit=byte";
  129. $first = $this->getJson($base)->assertOk()->json();
  130. expect(array_column($first['data'], 'para'))->toBe([1, 2, 3]);
  131. expect($first['meta'])->toMatchArray([
  132. 'page_size' => 250,
  133. 'page_size_unit' => 'byte',
  134. 'remaining_para' => 2,
  135. ]);
  136. $second = $this->getJson($base.'&from=4')->assertOk()->json();
  137. expect(array_column($second['data'], 'para'))->toBe([4, 5]);
  138. expect($second['meta']['remaining_para'])->toBe(0);
  139. });
  140. it('counts bytes from pali_texts for the translated paragraphs only', function () {
  141. // 只译了 1、3、5;每段原文 100 字节,250b 累加到第三段(para 5)才超限
  142. $channel = makeChapterFixture([1, 3, 5]);
  143. $first = $this->getJson("/api/v3/tipitaka-read-chapter?book=9002&para=1&channel={$channel}&pagesize=250&unit=byte")
  144. ->assertOk()
  145. ->json();
  146. expect(array_column($first['data'], 'para'))->toBe([1, 3, 5]);
  147. expect($first['meta']['remaining_para'])->toBe(0);
  148. });
  149. it('returns at least one paragraph when it alone exceeds the byte size', function () {
  150. $channel = makeChapterFixture();
  151. $items = $this->getJson("/api/v3/tipitaka-read-chapter?book=9002&para=1&channel={$channel}&pagesize=1&unit=byte")
  152. ->assertOk()
  153. ->json('data');
  154. expect(array_column($items, 'para'))->toBe([1]);
  155. });
  156. it('returns 404 when the channel has nothing translated in this chapter', function () {
  157. $channel = makeChapterFixture([]);
  158. $this->getJson("/api/v3/tipitaka-read-chapter?book=9002&para=1&channel={$channel}")
  159. ->assertStatus(404)
  160. ->assertJsonPath('title', 'Resource not found.');
  161. });
  162. it('clamps pagesize to the per-block ceiling and reports the effective value', function () {
  163. $channel = makeChapterFixture();
  164. $base = "/api/v3/tipitaka-read-chapter?book=9002&para=1&channel={$channel}";
  165. // 段落模式上限 200
  166. expect($this->getJson($base.'&pagesize=1000')->assertOk()->json('meta'))
  167. ->toMatchArray(['page_size' => 200, 'page_size_unit' => 'para']);
  168. // 字节模式上限 5000
  169. expect($this->getJson($base.'&pagesize=20000&unit=byte')->assertOk()->json('meta'))
  170. ->toMatchArray(['page_size' => 5000, 'page_size_unit' => 'byte']);
  171. // 没到上限的照常返回原值
  172. expect($this->getJson($base.'&pagesize=3')->assertOk()->json('meta.page_size'))->toBe(3);
  173. });
  174. it('accepts the id form, the cursor and the view parameter', function () {
  175. $channel = makeChapterFixture();
  176. $block = $this->getJson("/api/v3/tipitaka-read-chapter/9002-1?channel={$channel}&pagesize=1&from=2&view=all")
  177. ->assertOk()
  178. ->json();
  179. expect($block['data'])->toHaveCount(1);
  180. expect($block['data'][0])->toHaveKeys(['para', 'display', 'sentences']);
  181. expect($block['meta'])->toMatchArray(['chapter' => 1, 'first_para' => 2, 'remaining_para' => 3]);
  182. });
  183. it('rejects a bad id, channel, pagesize, unit, cursor or unknown chapter', function () {
  184. $channel = makeChapterFixture();
  185. // v3 用真实状态码 + RFC 9457 Problem Details,不再看 ok 字段
  186. $this->getJson("/api/v3/tipitaka-read-chapter/bad-id?channel={$channel}")
  187. ->assertStatus(422)
  188. ->assertJsonStructure(['type', 'title', 'status', 'detail']);
  189. $this->getJson('/api/v3/tipitaka-read-chapter/9002-1?channel=not-a-uuid')
  190. ->assertStatus(422)
  191. ->assertJsonPath('errors.channel.0', __('site.invalid_parameter'));
  192. $this->getJson("/api/v3/tipitaka-read-chapter?book=9002&channel={$channel}")
  193. ->assertStatus(422)
  194. ->assertJsonStructure(['errors' => ['para']]);
  195. $this->getJson("/api/v3/tipitaka-read-chapter?book=9002&para=99&channel={$channel}")
  196. ->assertStatus(404)
  197. ->assertJsonPath('title', 'Resource not found.');
  198. $this->getJson("/api/v3/tipitaka-read-chapter?book=9002&para=1&channel={$channel}&pagesize=0")
  199. ->assertStatus(422)
  200. ->assertJsonStructure(['errors' => ['pagesize']]);
  201. $this->getJson("/api/v3/tipitaka-read-chapter?book=9002&para=1&channel={$channel}&pagesize=10p")
  202. ->assertStatus(422)
  203. ->assertJsonStructure(['errors' => ['pagesize']]);
  204. $this->getJson("/api/v3/tipitaka-read-chapter?book=9002&para=1&channel={$channel}&unit=bytes")
  205. ->assertStatus(422)
  206. ->assertJsonStructure(['errors' => ['unit']]);
  207. $this->getJson("/api/v3/tipitaka-read-chapter/9002-1?channel={$channel}&from=0")
  208. ->assertStatus(422)
  209. ->assertJsonStructure(['errors' => ['from']]);
  210. });