getJson("/api/v3/tipitaka-reading/{$channel}?book=9001¶=1") ->assertOk() ->json('data.0'); expect($data['para'])->toBe(1); // 默认只输出 display expect($data)->not->toHaveKey('sentences'); expect($data['display']) ->toContain("
") ->toContain("
") ->toContain("
") ->toContain("
"); }); it('wraps a chapter title paragraph in a heading', function () { $channel = makeParagraphFixture(); (new PaliText)->forceFill([ 'book' => 9001, 'paragraph' => 1, 'level' => 2, 'class' => '', 'toc' => '', 'text' => '', 'html' => '', 'pcd_book_id' => 0, 'uid' => (string) Str::uuid(), ])->save(); $display = $this->getJson("/api/v3/tipitaka-reading/{$channel}?book=9001¶=1") ->assertOk() ->json('data.0.display'); expect($display)->toContain('

')->not->toContain('para-block'); }); it('can output only the sentences or both', function () { $channel = makeParagraphFixture(); $sentences = $this->getJson("/api/v3/tipitaka-reading/{$channel}?book=9001¶=1&include=sentences") ->assertOk() ->json('data.0'); expect($sentences)->not->toHaveKey('display'); expect($sentences['sentences'])->toHaveCount(2); expect($sentences['sentences'][0])->toHaveKeys(['sid', 'html']); $all = $this->getJson("/api/v3/tipitaka-reading/{$channel}?book=9001¶=1&include=all") ->assertOk() ->json('data.0'); expect($all)->toHaveKeys(['para', 'display', 'sentences']); }); it('lists the paragraphs of a range and skips empty ones', function () { $channel = makeParagraphFixture(); $items = $this->getJson("/api/v3/tipitaka-reading/{$channel}?book=9001¶=1&to=3") ->assertOk() ->json('data'); expect(array_column($items, 'para'))->toBe([1, 2]); }); it('outputs one line per sentence for non html formats', function () { $channel = makeParagraphFixture(); $display = $this->getJson("/api/v3/tipitaka-reading/{$channel}?book=9001¶=1&format=text") ->assertOk() ->json('data.0.display'); expect($display)->toBe("first sentence\nsecond sentence"); }); it('rejects a malformed channel in the path but validates filters as 422', function () { $channel = makeParagraphFixture(); // 路径参数由路由约束把关:不是 uuid 就匹配不上任何路由 → 404 $this->getJson('/api/v3/tipitaka-reading/not-a-uuid')->assertStatus(404); // filter 是查询参数,走 FormRequest → 422 $this->getJson("/api/v3/tipitaka-reading/{$channel}?book=9001¶=not-a-number") ->assertStatus(422) ->assertJsonStructure(['errors' => ['para']]); $this->getJson("/api/v3/tipitaka-reading/{$channel}?book=not-a-number") ->assertStatus(422) ->assertJsonStructure(['errors' => ['book']]); // book 只在跟 chapter / para / to 一起用时必填 $this->getJson("/api/v3/tipitaka-reading/{$channel}?para=1") ->assertStatus(422) ->assertJsonStructure(['errors' => ['book']]); // chapter 与 para 互斥 $this->getJson("/api/v3/tipitaka-reading/{$channel}?book=9001&chapter=1¶=1") ->assertStatus(422); }); it('fetches the whole channel when no filter is given', function () { $channel = makeParagraphFixture(); // 不带任何查询串 = 取这个 channel 的全部译文,下载场景要的就是这个 $json = $this->getJson("/api/v3/tipitaka-reading/{$channel}")->assertOk()->json(); expect(array_column($json['data'], 'para'))->toBe([1, 2]) ->and($json['data'][0]['book'])->toBe(9001) // 无 book 过滤时不给 total / remaining,靠 next_cursor 判断结束 ->and($json['meta'])->not->toHaveKey('total') ->and($json['meta']['next_cursor'])->toBeNull(); }); it('returns an empty collection for a paragraph with no content', function () { $channel = makeParagraphFixture(); // 这里是集合端点,没内容就是空集合而不是 404——区间口径下空段落本来就被跳过, // 单段只是区间长度为 1 的特例,两者口径要一致 $json = $this->getJson("/api/v3/tipitaka-reading/{$channel}?book=9001¶=9") ->assertOk() ->json(); expect($json['data'])->toBe([]) ->and($json['meta'])->toMatchArray(['total' => 0, 'remaining' => 0]) ->and($json['meta']['next_cursor'])->toBeNull(); }); it('rejects a range that ends before it starts', function () { $channel = makeParagraphFixture(); $this->getJson("/api/v3/tipitaka-reading/{$channel}?book=9001¶=3&to=1") ->assertStatus(422) ->assertJsonPath('errors.to.0', __('site.invalid_parameter')); }); it('caches the paragraph and drops the cache when a sentence changes', function () { $channel = makeParagraphFixture(); $url = "/api/v3/tipitaka-reading/{$channel}?book=9001¶=1"; $this->getJson($url)->assertOk(); $key = PaliContentService::paragraphCacheKey(9001, 1, $channel, 'html'); expect(Cache::has($key))->toBeTrue(); $sentence = Sentence::where('book_id', 9001)->where('paragraph', 1)->orderBy('word_start')->first(); $sentence->content = 'changed sentence'; $sentence->save(); expect(Cache::has($key))->toBeFalse(); expect($this->getJson($url)->json('data.0.display'))->toContain('changed sentence'); }); it('drops the cache when a sentence is added or deleted', function () { $channel = makeParagraphFixture(); $url = "/api/v3/tipitaka-reading/{$channel}?book=9001¶=1"; $this->getJson($url)->assertOk(); makeSentence($channel, 9001, 1, 3, 'third sentence'); expect($this->getJson($url.'&include=sentences')->json('data.0.sentences'))->toHaveCount(3); Sentence::where('book_id', 9001)->where('paragraph', 1)->where('word_start', 3)->first()->delete(); expect($this->getJson($url.'&include=sentences')->json('data.0.sentences'))->toHaveCount(2); });