diff --git a/BACKLOG.md b/BACKLOG.md index ca67da5..0de43ae 100644 --- a/BACKLOG.md +++ b/BACKLOG.md @@ -80,17 +80,6 @@ launch gate is the no-shell web installer; these ship continuously, whenever a u actions (password set/reset, lock/unlock); a general **options registry** (declared keys → `config` UI, per config-discipline) that **masks secrets** (`mail.smtp.password` et al. never rendered — consider a `secret` flag on the `config` table and/or at-rest encryption). *(Membership/invite UX stays app-side.)* -- **Comments, ratings & reviews — the last WP-parity gap** *(design of record: - [COMMENTS.md](COMMENTS.md)).* One core module, **off by default**, where **a review IS a comment - with a rating** — one `comment` table with a nullable `rating`, never a separate review store. - Attaches to **anything** through a subject-provider registry (`Tiger_Comment::registerSubject`, - modeled on `Tiger_Search`/`Tiger_Audience`): a CMS page, a blog article, a marketplace listing, a - shop product. 5 stars with **half-star display** of averages (whole-star input). Denormalized - `comment_aggregate` because a 60-card grid cannot average N rows per card. The differentiator is - the **verified reviewer** — Tiger has an entitlement oracle, so "every review is from someone who - bought it" is a claim WordPress structurally cannot make. Fills the rating/download overlay a - marketplace already publishes (`TigerMarketplace/docs/design/reputation.md` §8). - - **SMS OTP channel** — email OTP ships; add a `Tiger_Sms` transport (a `Tiger_Mail` sibling; SNS/Twilio, creds in DB config) + `requestLoginCodeSms`/`verifyLoginCodeSms` reusing the channel-agnostic `_completeCodeLogin` (`sms_otp`). Substrate built (`auth_challenge` + the `sms` credential factor). diff --git a/CAPABILITIES.md b/CAPABILITIES.md index 217d699..bdcace6 100644 --- a/CAPABILITIES.md +++ b/CAPABILITIES.md @@ -5,7 +5,7 @@ > before assuming something isn't built. `@api` = stable to build on; `@internal` = may change. > Grouped by **capability** (across layers), not by directory. -**195 classes** across **32 capabilities** · **19 modules**. Full prose: [FEATURES.md](FEATURES.md) (what) · [ARCHITECTURE.md](ARCHITECTURE.md) (why). Not-yet-built: [BACKLOG.md](BACKLOG.md). +**199 classes** across **33 capabilities** · **20 modules**. Full prose: [FEATURES.md](FEATURES.md) (what) · [ARCHITECTURE.md](ARCHITECTURE.md) (why). Not-yet-built: [BACKLOG.md](BACKLOG.md). ## Capabilities (`library/Tiger`) @@ -275,6 +275,7 @@ - **Tiger_View_Helper_PageField** `@api` — read a custom field value (Tiger_Fields) from a page on the front end. · `library/Tiger/View/Helper/PageField.php` - **Tiger_View_Helper_PageScript** `@api` — register a page-specific JS file from a view WITHOUT a ` diff --git a/tests/Integration/Comment/CommentRenderTest.php b/tests/Integration/Comment/CommentRenderTest.php new file mode 100644 index 0000000..75fd73e --- /dev/null +++ b/tests/Integration/Comment/CommentRenderTest.php @@ -0,0 +1,168 @@ + 'test.thing', + 'label' => 'Thing', + 'resolve' => static fn ($id) => ['title' => 'A Thing', 'url' => '/thing/' . $id, 'exists' => true], + 'ratings' => true, + ]); + } + + protected function tearDown(): void + { + Tiger_Comment::reset(); + parent::tearDown(); + } + + #[Test] + public function the_thread_renders_a_mount_point_not_the_comments(): void + { + $html = (new Comment_Service_Render())->thread('test.thing:t1'); + + $this->assertStringContainsString('data-comment-subject="test.thing:t1"', $html); + $this->assertStringContainsString('tiger-comments-list', $html); + $this->assertStringContainsString('tiger-comments-form', $html); + } + + #[Test] + public function an_unregistered_subject_renders_nothing_at_all(): void + { + $render = new Comment_Service_Render(); + + $this->assertSame('', $render->thread('not.registered:1')); + $this->assertSame('', $render->stars('not.registered:1')); + $this->assertSame('', $render->summary('not.registered:1')); + } + + #[Test] + public function a_malformed_subject_renders_nothing(): void + { + $this->assertSame('', (new Comment_Service_Render())->thread('no-colon-here')); + } + + #[Test] + public function stars_render_nothing_until_something_is_rated(): void + { + $this->assertSame('', (new Comment_Service_Render())->stars('test.thing:unrated')); + $this->assertSame('', (new Comment_Service_Render())->summary('test.thing:unrated')); + } + + #[Test] + public function stars_and_summary_render_server_side_from_the_rollup(): void + { + $agg = new Tiger_Model_CommentAggregate(); + $agg->insert([ + 'subject_type' => 'test.thing', 'subject_id' => 'rated', + 'comment_count' => 3, 'rating_count' => 3, 'rating_avg' => 4.33, + 'star_1' => 0, 'star_2' => 0, 'star_3' => 1, 'star_4' => 0, 'star_5' => 2, + ]); + + $render = new Comment_Service_Render(); + + $stars = $render->stars('test.thing:rated'); + $this->assertStringContainsString('aria-label="4.5 out of 5 stars', $stars, '4.33 snaps to 4.5'); + $this->assertStringContainsString('(3)', $stars); + + $summary = $render->summary('test.thing:rated'); + $this->assertStringContainsString('progress-bar', $summary, 'the histogram renders'); + $this->assertStringContainsString('aria-valuenow="67"', $summary, '2 of 3 ratings are 5-star'); + } + + /** The batch read is the whole reason the rollup table exists — one query for a grid of cards. */ + #[Test] + public function rollups_can_be_read_in_batch(): void + { + $agg = new Tiger_Model_CommentAggregate(); + $agg->insert(['subject_type' => 'test.thing', 'subject_id' => 'a', 'rating_count' => 1, 'rating_avg' => 5.00]); + $agg->insert(['subject_type' => 'test.thing', 'subject_id' => 'b', 'rating_count' => 2, 'rating_avg' => 3.50]); + + $out = $agg->forSubjects('test.thing', ['a', 'b', 'missing']); + + $this->assertCount(2, $out, 'a subject with no rollup is simply absent'); + $this->assertSame(5.0, $out['a']['rating_avg']); + $this->assertSame(2, $out['b']['rating_count']); + } + + #[Test] + public function an_empty_batch_read_is_not_a_query(): void + { + $this->assertSame([], (new Tiger_Model_CommentAggregate())->forSubjects('test.thing', [])); + } + + #[Test] + public function an_unknown_subject_has_the_zero_rollup(): void + { + $out = (new Tiger_Model_CommentAggregate())->forSubject('test.thing', 'never-seen'); + + $this->assertSame(0, $out['rating_count']); + $this->assertSame(0.0, $out['rating_avg']); + $this->assertSame(0, $out['stars'][5]); + } + + #[Test] + public function the_page_resolver_finds_a_cms_page(): void + { + $pages = new Tiger_Model_Page(); + $id = (string) $pages->insert([ + 'page_key' => 'test-comment-page', 'slug' => 'test-comment-page', + 'title' => 'Commentable', 'type' => Tiger_Model_Page::TYPE_PAGE, + 'status' => Tiger_Model_Page::STATUS_PUBLISHED, 'format' => Tiger_Model_Page::FORMAT_HTML, + 'body' => '
hi
', 'locale' => 'en', + ]); + + $out = Comment_Service_Subjects::page($id); + + $this->assertTrue($out['exists']); + $this->assertSame('Commentable', $out['title']); + $this->assertSame('/test-comment-page', $out['url']); + } + + /** A deleted subject must resolve to "gone", not blow up the moderation queue. */ + #[Test] + public function the_page_resolver_reports_a_missing_page_as_gone(): void + { + $out = Comment_Service_Subjects::page('00000000-0000-0000-0000-000000000000'); + + $this->assertFalse($out['exists']); + $this->assertSame('', $out['title']); + } + + #[Test] + public function the_blog_resolver_is_safe_when_the_module_is_absent(): void + { + $out = Comment_Service_Subjects::blogPost('whatever'); + + $this->assertIsArray($out); + $this->assertArrayHasKey('exists', $out); + } +} diff --git a/tests/Integration/Comment/CommentServiceTest.php b/tests/Integration/Comment/CommentServiceTest.php new file mode 100644 index 0000000..0f6d872 --- /dev/null +++ b/tests/Integration/Comment/CommentServiceTest.php @@ -0,0 +1,282 @@ + 'test.thing', + 'label' => 'Thing', + 'resolve' => static fn ($id) => ['title' => 'A Thing', 'url' => '/thing/' . $id, 'exists' => $id !== 'gone'], + 'ratings' => true, + 'owns' => static fn ($id, $uid) => $uid === 'owner-user', + ]); + $this->enable(true); + } + + protected function tearDown(): void + { + $reg = \Zend_Registry::getInstance(); + if ($reg->offsetExists('tiger.auth.stateless')) { $reg->offsetUnset('tiger.auth.stateless'); } + Tiger_Comment::reset(); + parent::tearDown(); + } + + /** + * Flip the feature flag. + * + * `Tiger_Comment::isEnabled()` reads the merged `Zend_Config` the bootstrap publishes, and the + * integration harness boots no front controller — so the registry entry may not exist at all. + * Seed it when absent rather than assuming a booted app. + */ + private function enable(bool $on): void + { + (new Tiger_Model_Config())->set('global', '', Tiger_Comment::CONFIG_ENABLED, $on ? '1' : '0'); + + $flag = ['tiger' => ['comment' => ['enabled' => $on ? '1' : '0']]]; + + // A config published by a real bootstrap is READ-ONLY, and another test in the same run may + // have registered one — so merge onto a modifiable COPY rather than mutating in place, which + // throws "Zend_Config is read only" only when the full suite runs. Isolation-passing, + // suite-failing is exactly the bug this avoids. + if (\Zend_Registry::isRegistered('Zend_Config')) { + $existing = \Zend_Registry::get('Zend_Config'); + $merged = new \Zend_Config($existing->toArray(), true); + $merged->merge(new \Zend_Config($flag, true)); + \Zend_Registry::set('Zend_Config', $merged); + } else { + \Zend_Registry::set('Zend_Config', new \Zend_Config($flag, true)); + } + } + + private function post(array $params): array + { + $svc = new Comment_Service_Comment(); + $svc->post($params + ['subject' => 'test.thing:t1', 'body' => 'Nice one', '_t' => time() - 10]); + $res = $svc->getResponse(); + return ['result' => (int) $res->result, 'data' => (array) $res->data, 'messages' => $res->messages]; + } + + #[Test] + public function a_disabled_install_refuses_every_call(): void + { + $this->enable(false); + + $out = $this->post([]); + + $this->assertSame(0, $out['result'], 'comments are off by default and must behave as if absent'); + } + + #[Test] + public function an_unregistered_subject_is_refused(): void + { + $svc = new Comment_Service_Comment(); + $svc->post(['subject' => 'not.registered:1', 'body' => 'hi', '_t' => time() - 10]); + + $this->assertSame(0, (int) $svc->getResponse()->result); + } + + #[Test] + public function a_subject_the_provider_says_is_gone_takes_no_comments(): void + { + $svc = new Comment_Service_Comment(); + $svc->post(['subject' => 'test.thing:gone', 'body' => 'hi', '_t' => time() - 10]); + + $this->assertSame(0, (int) $svc->getResponse()->result); + } + + #[Test] + public function the_honeypot_rejects_a_bot(): void + { + $out = $this->post(['_hp' => 'http://spam.example']); + + $this->assertSame(0, $out['result'], 'a field no human can see was filled'); + } + + #[Test] + public function an_instant_submission_is_rejected(): void + { + $out = $this->post(['_t' => time()]); + + $this->assertSame(0, $out['result'], 'rendered and submitted in under a second'); + } + + #[Test] + public function an_empty_comment_with_no_rating_is_refused(): void + { + $out = $this->post(['body' => ' ']); + + $this->assertSame(0, $out['result']); + } + + #[Test] + public function a_star_only_rating_is_accepted(): void + { + $this->loginAs('user'); + $out = $this->post(['body' => '', 'rating' => 5]); + + $this->assertSame(1, $out['result'], 'a rating with no words is a legitimate review'); + } + + #[Test] + public function a_new_comment_is_held_for_moderation_by_default(): void + { + $this->loginAs('user'); + $out = $this->post([]); + + $this->assertSame(1, $out['result']); + $this->assertSame(Tiger_Model_Comment::STATUS_PENDING, $out['data']['status']); + } + + /** A pending comment must not move a public average — otherwise posting alone shifts a score. */ + #[Test] + public function a_pending_rating_does_not_move_the_average(): void + { + $this->loginAs('user'); + $this->post(['rating' => 5]); + + $agg = (new Tiger_Model_CommentAggregate())->forSubject('test.thing', 't1'); + $this->assertSame(0, $agg['rating_count']); + $this->assertSame(0.0, $agg['rating_avg']); + } + + #[Test] + public function approving_a_rating_updates_the_rollup(): void + { + $this->loginAs('user'); + $this->post(['rating' => 4]); + + $row = (new Tiger_Model_Comment())->byStatus(Tiger_Model_Comment::STATUS_PENDING, 5)[0]; + + $svc = new Comment_Service_Comment(); + $svc->moderate(['comment_id' => $row['comment_id'], 'status' => Tiger_Model_Comment::STATUS_APPROVED]); + + $agg = (new Tiger_Model_CommentAggregate())->forSubject('test.thing', 't1'); + $this->assertSame(1, $agg['rating_count']); + $this->assertSame(4.0, $agg['rating_avg']); + $this->assertSame(1, $agg['stars'][4]); + } + + #[Test] + public function a_second_rating_edits_the_first_rather_than_stacking(): void + { + $this->loginAs('user'); + $this->post(['rating' => 2]); + $this->post(['rating' => 5]); + + $rows = (new Tiger_Model_Comment())->byStatus(Tiger_Model_Comment::STATUS_PENDING, 10); + $rated = array_filter($rows, static fn ($r) => $r['rating'] !== null); + + $this->assertCount(1, $rated, 'one rating per user per subject'); + $this->assertSame(5, (int) reset($rated)['rating']); + } + + #[Test] + public function the_subject_owner_cannot_rate_their_own_thing(): void + { + $identity = $this->loginAs('user'); + $identity->user_id = 'owner-user'; // the provider says this user owns the subject + + $svc = new Comment_Service_Comment(); + $svc->post(['subject' => 'test.thing:t1', 'body' => 'Mine is great', 'rating' => 5, '_t' => time() - 10]); + + $this->assertSame(0, (int) $svc->getResponse()->result, 'self-review is the obvious way to poison ratings'); + } + + #[Test] + public function a_comment_without_a_rating_is_fine_from_the_owner(): void + { + $identity = $this->loginAs('user'); + $identity->user_id = 'owner-user'; + + $svc = new Comment_Service_Comment(); + $svc->post(['subject' => 'test.thing:t1', 'body' => 'Thanks for the feedback', '_t' => time() - 10]); + + $this->assertSame(1, (int) $svc->getResponse()->result, 'a vendor may reply, just not rate'); + } + + #[Test] + public function a_guest_is_refused_unless_guests_are_allowed(): void + { + $out = $this->post([]); // not signed in + + $this->assertSame(0, $out['result']); + } + + #[Test] + public function the_public_thread_shows_only_approved_comments(): void + { + $this->loginAs('user'); + $this->post(['body' => 'held back']); + + $svc = new Comment_Service_Comment(); + $svc->list(['subject' => 'test.thing:t1']); + $data = (array) $svc->getResponse()->data; + + $this->assertSame([], $data['comments']); + $this->assertTrue($data['ratings'], 'the subject accepts stars'); + } + + #[Test] + public function a_public_projection_never_leaks_an_email_or_ip(): void + { + $this->loginAs('user'); + $this->post([]); + $row = (new Tiger_Model_Comment())->byStatus(Tiger_Model_Comment::STATUS_PENDING, 5)[0]; + + $svc = new Comment_Service_Comment(); + $svc->moderate(['comment_id' => $row['comment_id'], 'status' => Tiger_Model_Comment::STATUS_APPROVED]); + + $svc = new Comment_Service_Comment(); + $svc->list(['subject' => 'test.thing:t1']); + $comment = ((array) $svc->getResponse()->data)['comments'][0]; + + $this->assertArrayNotHasKey('author_email', $comment); + $this->assertArrayNotHasKey('ip', $comment); + $this->assertArrayNotHasKey('user_agent', $comment); + } + + #[Test] + public function deleting_a_comment_updates_the_rollup(): void + { + $this->loginAs('user'); + $this->post(['rating' => 5]); + $row = (new Tiger_Model_Comment())->byStatus(Tiger_Model_Comment::STATUS_PENDING, 5)[0]; + + $svc = new Comment_Service_Comment(); + $svc->moderate(['comment_id' => $row['comment_id'], 'status' => Tiger_Model_Comment::STATUS_APPROVED]); + $this->assertSame(1, (new Tiger_Model_CommentAggregate())->forSubject('test.thing', 't1')['rating_count']); + + $svc = new Comment_Service_Comment(); + $svc->delete(['comment_id' => $row['comment_id']]); + + $agg = (new Tiger_Model_CommentAggregate())->forSubject('test.thing', 't1'); + $this->assertSame(0, $agg['rating_count'], 'the rollup can never outlive the thread it summarizes'); + $this->assertSame(0.0, $agg['rating_avg']); + } +} diff --git a/tests/Unit/Comment/CommentTest.php b/tests/Unit/Comment/CommentTest.php new file mode 100644 index 0000000..5680b4f --- /dev/null +++ b/tests/Unit/Comment/CommentTest.php @@ -0,0 +1,177 @@ + 'shop.product', + 'label' => 'Product', + 'resolve' => static fn ($id) => ['title' => 'Blue Widget', 'url' => '/shop/blue', 'exists' => true], + 'ratings' => true, + ]; + } + + #[Test] + public function registersAndResolvesASubject(): void + { + Tiger_Comment::registerSubject($this->provider()); + + $s = Tiger_Comment::subject('shop.product'); + $this->assertSame('Product', $s['label']); + $this->assertTrue($s['ratings']); + $this->assertSame(1, $s['threading'], 'threading defaults to one reply level'); + } + + #[Test] + public function aProviderWithoutAKeyOrLabelIsRefused(): void + { + Tiger_Comment::registerSubject(['label' => 'No key']); + Tiger_Comment::registerSubject(['key' => 'no.label']); + + $this->assertSame([], Tiger_Comment::subjects()); + } + + #[Test] + public function anUnregisteredTypeIsNeverTrusted(): void + { + $this->assertNull(Tiger_Comment::subject('made.up')); + $this->assertFalse(Tiger_Comment::acceptsRatings('made.up')); + $this->assertSame( + ['title' => '', 'url' => '', 'exists' => false], + Tiger_Comment::resolve('made.up', '1') + ); + } + + #[Test] + public function ratingsArePerSubjectNotGlobal(): void + { + Tiger_Comment::registerSubject($this->provider()); + Tiger_Comment::registerSubject($this->provider(['key' => 'page', 'ratings' => false])); + + $this->assertTrue(Tiger_Comment::acceptsRatings('shop.product')); + $this->assertFalse(Tiger_Comment::acceptsRatings('page'), 'a CMS page takes discussion, not stars'); + } + + #[Test] + public function resolveReturnsTheProvidersAnswer(): void + { + Tiger_Comment::registerSubject($this->provider()); + + $this->assertSame( + ['title' => 'Blue Widget', 'url' => '/shop/blue', 'exists' => true], + Tiger_Comment::resolve('shop.product', 'abc') + ); + } + + /** A moderation queue must still render the row whose subject blew up — that's when you need it. */ + #[Test] + public function aThrowingResolverDegradesToGone(): void + { + Tiger_Comment::registerSubject($this->provider([ + 'resolve' => static function () { throw new RuntimeException('db down'); }, + ])); + + $this->assertFalse(Tiger_Comment::resolve('shop.product', 'abc')['exists']); + } + + #[Test] + public function verifiedReviewerConsultsTheEntitlementGate(): void + { + Tiger_Comment::registerSubject($this->provider([ + 'may_review' => static fn ($id, $userId) => $userId === 'buyer', + ])); + + $this->assertTrue(Tiger_Comment::isVerifiedReviewer('shop.product', 'x', 'buyer')); + $this->assertFalse(Tiger_Comment::isVerifiedReviewer('shop.product', 'x', 'stranger')); + } + + #[Test] + public function aGuestIsNeverVerified(): void + { + Tiger_Comment::registerSubject($this->provider(['may_review' => static fn () => true])); + + $this->assertFalse(Tiger_Comment::isVerifiedReviewer('shop.product', 'x', null)); + $this->assertFalse(Tiger_Comment::isVerifiedReviewer('shop.product', 'x', '')); + } + + /** A failing entitlement check must not mint a trust badge. */ + #[Test] + public function aThrowingEntitlementGateIsNotVerified(): void + { + Tiger_Comment::registerSubject($this->provider([ + 'may_review' => static function () { throw new RuntimeException('authority down'); }, + ])); + + $this->assertFalse(Tiger_Comment::isVerifiedReviewer('shop.product', 'x', 'buyer')); + } + + #[Test] + public function aSubjectWithNoEntitlementGateVerifiesNobody(): void + { + Tiger_Comment::registerSubject($this->provider()); // no may_review — a blog post has none + + $this->assertFalse(Tiger_Comment::isVerifiedReviewer('shop.product', 'x', 'buyer')); + } + + #[Test] + public function ownershipGatesSelfReview(): void + { + Tiger_Comment::registerSubject($this->provider([ + 'owns' => static fn ($id, $userId) => $userId === 'vendor', + ])); + + $this->assertTrue(Tiger_Comment::ownsSubject('shop.product', 'x', 'vendor')); + $this->assertFalse(Tiger_Comment::ownsSubject('shop.product', 'x', 'shopper')); + } + + #[Test] + public function aLaterRegistrationOfTheSameKeyWins(): void + { + Tiger_Comment::registerSubject($this->provider(['label' => 'First'])); + Tiger_Comment::registerSubject($this->provider(['label' => 'Second'])); + + $this->assertSame('Second', Tiger_Comment::subject('shop.product')['label']); + $this->assertCount(1, Tiger_Comment::subjects()); + } + + /** Halves come from AVERAGING, never from a half-star picker (COMMENTS.md §4). */ + #[Test] + public function halfStarSnapsToTheNearestHalf(): void + { + $this->assertSame(4.5, Tiger_Comment::halfStar(4.3)); + $this->assertSame(4.0, Tiger_Comment::halfStar(4.2)); + $this->assertSame(5.0, Tiger_Comment::halfStar(4.9)); + $this->assertSame(0.0, Tiger_Comment::halfStar(0)); + $this->assertSame(3.5, Tiger_Comment::halfStar(3.5)); + } + + #[Test] + public function halfStarClampsOutOfRangeInput(): void + { + $this->assertSame(5.0, Tiger_Comment::halfStar(9.9)); + $this->assertSame(0.0, Tiger_Comment::halfStar(-2)); + } +} diff --git a/tests/Unit/Comment/StarsHelperTest.php b/tests/Unit/Comment/StarsHelperTest.php new file mode 100644 index 0000000..b9d7680 --- /dev/null +++ b/tests/Unit/Comment/StarsHelperTest.php @@ -0,0 +1,99 @@ +setView(new Zend_View()); + return $helper; + } + + #[Test] + public function aWholeAverageRendersFiveSolidStates(): void + { + $html = $this->helper()->stars(3.0); + + $this->assertSame(3, substr_count($html, 'fa-solid fa-star"'), 'three filled'); + $this->assertSame(2, substr_count($html, 'fa-regular fa-star'), 'two empty'); + $this->assertStringNotContainsString('fa-star-half-stroke', $html); + } + + #[Test] + public function aHalfAverageRendersAHalfStar(): void + { + $html = $this->helper()->stars(4.3); // snaps to 4.5 + + $this->assertSame(4, substr_count($html, 'fa-solid fa-star"')); + $this->assertStringContainsString('fa-star-half-stroke', $html); + } + + #[Test] + public function theRowIsLabelledForAssistiveTech(): void + { + $html = $this->helper()->stars(4.5); + + $this->assertStringContainsString('role="img"', $html); + $this->assertStringContainsString('aria-label="4.5 out of 5 stars"', $html); + } + + #[Test] + public function theCountIsIncludedInTheLabelWhenGiven(): void + { + $html = $this->helper()->stars(4.0, ['count' => 12]); + + $this->assertStringContainsString('from 12 ratings', $html); + $this->assertStringContainsString('(12)', $html); + } + + /** "4", not "4.0" — a trailing zero reads like false precision. */ + #[Test] + public function aWholeNumberDropsItsTrailingZero(): void + { + $this->assertStringContainsString('>4', $this->helper()->stars(4.0)); + $this->assertStringContainsString('>4.5', $this->helper()->stars(4.5)); + } + + #[Test] + public function theNumericValueCanBeSuppressed(): void + { + $html = $this->helper()->stars(4.0, ['show_value' => false]); + + $this->assertStringNotContainsString('tiger-stars-value', $html); + $this->assertStringContainsString('aria-label', $html, 'the label survives — it is not decoration'); + } + + #[Test] + public function extraClassesAndSizeAreApplied(): void + { + $html = $this->helper()->stars(2.0, ['size' => 'sm', 'class' => 'mb-2']); + + $this->assertStringContainsString('small', $html); + $this->assertStringContainsString('mb-2', $html); + } + + #[Test] + public function aZeroAverageStillRendersFiveEmptyStars(): void + { + $html = $this->helper()->stars(0); + + $this->assertSame(5, substr_count($html, 'fa-regular fa-star')); + } +}