Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .codex/story-4/EVIDENCE.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,3 +78,7 @@
## Batch 2 Task 4 ? comment submission
- Actual local screenshots in .codex/story-4/evidence/: comment-invalid.png, comment-pending.png, comment-success.png, comment-error.png, comment-unknown.png, comment-expired.png. Browser storage profile fixture; real backend POST 201 and personalized latest-three GET 200. Explicit 400 and aborted-response fixtures cover failure recovery. No automatic POST retry; Guest/expiration produced no protected request.
- Verified Enter/Shift+Enter, 501-character rejection, disabled pending controls, empty-after-success, server total 3 and three latest rows, safe textContent rendering, draft preservation, Guest hiding. All required checks passed: 124 tests, 45% statements. Story 3 browser regression passed.
## Batch 2 Task 5 ? comment likes
- Genuine local screenshots: like-pending.png, like-success.png, like-rejected.png, like-unknown.png, like-expired.png, like-guest.png in .codex/story-4/evidence/. Uploaded with task PR.
- Real backend like/unlike returned 200/200; isolated profile identity was restored to original false/count. Personalized reload returned active state. Explicit browser 403/aborted and late-response fixtures tested recovery without repeated toggle or expired UI resurrection. Guest/expired protected requests: zero.
- All required checks passed: 16 suites / 133 tests; 47.21% statements. Story 3 regression passed, no page errors.
1 change: 1 addition & 0 deletions .codex/story-4/PROGRESS.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,3 +46,4 @@

- PR #39 merged at af700b4. Favorites implemented with personalized GET, exact guarded POST, pending lock, confirmed response state/count, read-only reconciliation after unknown outcomes, stale-session rejection. Fixed erasableSyntaxOnly class syntax and inactive feedback display; all checks passed (105 tests, 39.59%). Browser real add/remove returned 200 and restored isolated identity favorite to false; injected 400 and lost response verified recovery, Guest/expiration sent no POST.
- PR #40 confirmed merged at 846a68f. Comment submission now validates 1?500 trimmed characters, locks input/send, supports Enter/Shift+Enter, retains drafts on failures, and refreshes personalized latest-three comments after confirmed 201. Added textContent rendering and server-total checks. Browser real creation passed; backend strips tags, so harness assertion was corrected without changing app behavior. Three isolated verification comments remain on Cozy Sudoku. All checks passed: 124 tests / 15 suites, 45% statements. Story 3 regression checks passed with no page errors.
- PR #41 merged at aba9c4d. Comment likes now use personalized initial state, exact guarded UUID toggle, pending indicator/lock, confirmed state/count, and read-only reconciliation after unknown results. Old controllers disposed before list refresh; late responses cannot restore expired state. All checks passed: 133 tests / 16 suites, 47.21% statements. Browser real like/unlike 200/200 restored original state/count; personalized reload, injected 403/aborted recovery, Guest/expired no POST and late-response expiration verified. Story 3 regression passed.
8 changes: 4 additions & 4 deletions .codex/story-4/TASKS.md
Original file line number Diff line number Diff line change
Expand Up @@ -96,18 +96,18 @@ Prerequisite: PR #30 MERGED at 90dbaa5. Provider enabled status confirmed by own
## RSS-QS-4-2-2 (20 points)

- Official specification: https://github.com/rolling-scopes-school/qualifying-stage/blob/main/tasks/minigames/tasks/story-4/RSS-QS-4-2-2-comment-submission-form.md
- Status: VERIFIED
- Status: MERGED
- Branch: comment-submission
- Dependencies: preceding task PR merged; Batch 1 complete.
- Tests: 124 passed, 45% statements; screenshots captured; commits 904576a, 2215dec, a501aa0, 59ee926; PR pending; merge pending
- Tests: 124 passed, 45% statements; screenshots attached; commits 904576a, 2215dec, a501aa0, 59ee926, 43e3277; PR https://github.com/abnzrdev/minigames/pull/41; merge aba9c4d82db0dd5ec90eaabd955e71c6f74b4d8a (GitHub MERGED)

## RSS-QS-4-2-4 (15 points)

- Official specification: https://github.com/rolling-scopes-school/qualifying-stage/blob/main/tasks/minigames/tasks/story-4/RSS-QS-4-2-4-like-comment-api.md
- Status: NOT_STARTED
- Status: VERIFIED
- Branch: comment-likes
- Dependencies: preceding task PR merged; Batch 1 complete.
- Tests: pending; screenshots: pending; commits: pending; PR: pending; merge: pending.
- Tests: 133 passed, 47.21% statements; genuine screenshots captured; commits f26a430, 28a007e; PR pending; merge pending

## RSS-QS-4-4-3 (35 points)

Expand Down
30 changes: 29 additions & 1 deletion src/api/comments.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { fetchGameComments, submitComment } from './comments';
import { fetchGameComments, submitComment, toggleCommentLike } from './comments';
import { commentAuthorName, validateCommentText } from '../utils/commentValidation';
const state = vi.hoisted(() => ({
session: { displayName: 'Alice', email: 'a+b@example.com', authenticatedAt: 1 } as {
Expand Down Expand Up @@ -72,4 +72,32 @@ describe('comment API and validation', () => {
'Player'
);
});
it('encodes the comment UUID and accepts only confirmed toggle state/count', async () => {
fetchMock.mockResolvedValueOnce(
new Response(JSON.stringify({ data: { isLikedByCurrentUser: true, likesCount: 31 } }))
);
await expect(toggleCommentLike('a/b', profile)).resolves.toEqual({
active: true,
likesCount: 31,
});
expect(fetchMock.mock.calls[0][0]).toContain('/api/comments/a%2Fb/like');
expect(fetchMock.mock.calls[0][1]).toMatchObject({
method: 'POST',
body: JSON.stringify({ userEmail: profile.email }),
});
fetchMock.mockResolvedValueOnce(
new Response(JSON.stringify({ data: { isLikedByCurrentUser: 'true', likesCount: -1 } }))
);
await expect(toggleCommentLike('id', profile)).rejects.toMatchObject({ kind: 'unknown' });
});
it('does not dispatch a comment like after the session expires', async () => {
state.session = null;
await expect(toggleCommentLike('id', profile)).rejects.toMatchObject({ kind: 'unauthorized' });
expect(fetchMock).not.toHaveBeenCalled();
});
it('does not replay an ambiguous comment toggle', async () => {
fetchMock.mockRejectedValue(Error('lost response'));
await expect(toggleCommentLike('id', profile)).rejects.toMatchObject({ kind: 'unknown' });
expect(fetchMock).toHaveBeenCalledOnce();
});
});
15 changes: 14 additions & 1 deletion src/api/comments.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { MutationFailure, postSessionMutation } from './mutations';
import { MutationFailure, postSessionMutation, readToggleResult } from './mutations';
import type { AppSession } from '../utils/appSession';
import { commentAuthorName, validateCommentText } from '../utils/commentValidation';

Expand All @@ -9,6 +9,7 @@ export interface GameComment {
authorName: string;
text: string;
likesCount: number;
isLikedByCurrentUser?: boolean;
createdAt: string;
}

Expand Down Expand Up @@ -60,3 +61,15 @@ export async function submitComment(
}
return data as GameComment;
}

export async function toggleCommentLike(
commentId: string,
session: AppSession
): Promise<{ active: boolean; likesCount: number }> {
const data = await postSessionMutation(
`/api/comments/${encodeURIComponent(commentId)}/like`,
{},
session
);
return readToggleResult(data, 'isLikedByCurrentUser');
}
133 changes: 133 additions & 0 deletions src/components/CommentLikeControl.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
// @vitest-environment jsdom
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { AppSession } from '../utils/appSession';
const mocks = vi.hoisted(() => ({
toggle: vi.fn(),
auth: vi.fn(),
snackbar: vi.fn(),
session: null as AppSession | null,
listeners: [] as Array<(session: AppSession | null) => void>,
}));
vi.mock('../api/comments', () => ({ toggleCommentLike: mocks.toggle }));
vi.mock('../utils/session', () => ({
appSession: {
recoveryVersion: 0,
check: () => mocks.session,
subscribe: (fn: (session: AppSession | null) => void) => {
mocks.listeners.push(fn);
fn(mocks.session);
return () => {};
},
},
}));
vi.mock('../utils/authDialog', () => ({ openAuthDialog: mocks.auth }));
vi.mock('./Snackbar', () => ({ showSnackbar: mocks.snackbar }));
import { MutationFailure } from '../api/mutations';
import { initializeCommentLike } from './CommentLikeControl';
describe('comment like control', () => {
let row: HTMLElement, button: HTMLButtonElement, cleanup: () => void;
const refresh = vi.fn<() => Promise<void>>();
beforeEach(() => {
vi.clearAllMocks();
mocks.listeners.length = 0;
mocks.session = { displayName: 'Alice', email: 'alice@example.com', authenticatedAt: 1 };
row = document.createElement('div');
row.innerHTML =
'<button class="game-details__like"><span data-comment-likes>10</span></button>';
document.body.replaceChildren(row);
button = row.querySelector('button')!;
cleanup = initializeCommentLike(
row,
{
commentId: 'id',
authorName: 'Alice',
text: 'Text',
createdAt: new Date().toISOString(),
likesCount: 10,
isLikedByCurrentUser: true,
},
refresh
);
});
afterEach(() => cleanup());
it('initializes personalized state and updates only the confirmed server count/state', async () => {
expect(button.getAttribute('aria-pressed')).toBe('true');
let resolve: (value: { active: boolean; likesCount: number }) => void = () => {};
mocks.toggle.mockReturnValue(
new Promise((r) => {
resolve = r;
})
);
button.click();
button.click();
expect(mocks.toggle).toHaveBeenCalledOnce();
expect(button.disabled).toBe(true);
expect(button.getAttribute('aria-busy')).toBe('true');
expect(row.querySelector('[data-comment-likes]')?.textContent).toBe('10');
expect(button.getAttribute('aria-pressed')).toBe('true');
resolve({ active: false, likesCount: 77 });
await vi.waitFor(() => expect(button.disabled).toBe(false));
expect(button.getAttribute('aria-pressed')).toBe('false');
expect(row.querySelector('[data-comment-likes]')?.textContent).toBe('77');
});
it('retains confirmed state and unlocks after definitive rejection', async () => {
mocks.toggle.mockRejectedValue(new MutationFailure('rejected', '400'));
button.click();
await vi.waitFor(() => expect(mocks.snackbar).toHaveBeenCalled());
expect(button.disabled).toBe(false);
expect(button.getAttribute('aria-pressed')).toBe('true');
expect(row.querySelector('[data-comment-likes]')?.textContent).toBe('10');
});
it('locks an unknown result until read-only refresh without another toggle', async () => {
mocks.toggle.mockRejectedValue(new MutationFailure('unknown', 'lost'));
button.click();
await vi.waitFor(() => expect(row.querySelector('.game-details__retry')).not.toBeNull());
button.click();
expect(mocks.toggle).toHaveBeenCalledOnce();
expect(button.disabled).toBe(true);
row.querySelector<HTMLButtonElement>('.game-details__retry')!.click();
expect(refresh).toHaveBeenCalledOnce();
expect(mocks.toggle).toHaveBeenCalledOnce();
});
it('warns and opens Auth for Guests without a POST', () => {
mocks.session = null;
mocks.listeners.forEach((fn) => fn(null));
button.click();
expect(button.getAttribute('aria-pressed')).toBe('false');
expect(mocks.toggle).not.toHaveBeenCalled();
expect(mocks.auth).toHaveBeenCalledWith('login', true);
expect(mocks.snackbar).toHaveBeenCalled();
});
it('ignores a late response after expiry or cleanup', async () => {
let resolve: (value: { active: boolean; likesCount: number }) => void = () => {};
mocks.toggle.mockReturnValue(
new Promise((r) => {
resolve = r;
})
);
button.click();
mocks.session = null;
mocks.listeners.forEach((fn) => fn(null));
resolve({ active: true, likesCount: 12 });
await vi.waitFor(() => expect(button.disabled).toBe(false));
expect(button.getAttribute('aria-pressed')).toBe('false');
expect(row.querySelector('[data-comment-likes]')?.textContent).toBe('10');
expect(mocks.snackbar).not.toHaveBeenCalled();
cleanup();
expect(row.querySelector('[role="status"]')).toBeNull();
});
it('ignores another authenticated identity after the request starts', async () => {
let resolve: (value: { active: boolean; likesCount: number }) => void = () => {};
mocks.toggle.mockReturnValue(
new Promise((r) => {
resolve = r;
})
);
button.click();
mocks.session = { ...mocks.session!, email: 'different@example.com', authenticatedAt: 2 };
resolve({ active: false, likesCount: 2 });
await vi.waitFor(() => expect(button.disabled).toBe(false));
expect(row.querySelector('[data-comment-likes]')?.textContent).toBe('10');
expect(mocks.snackbar).not.toHaveBeenCalled();
});
});
96 changes: 96 additions & 0 deletions src/components/CommentLikeControl.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
import { toggleCommentLike, type GameComment } from '../api/comments';
import { MutationFailure } from '../api/mutations';
import { appSession } from '../utils/session';
import { ensureActiveSession } from '../utils/protectedAction';
import { showSnackbar } from './Snackbar';

export function initializeCommentLike(
row: HTMLElement,
comment: GameComment,
refresh: () => Promise<void>
): () => void {
const button = row.querySelector<HTMLButtonElement>('.game-details__like')!;
const count = button.querySelector<HTMLElement>('[data-comment-likes]')!;
const status = document.createElement('p');
status.className = 'game-details__like-status';
status.setAttribute('role', 'status');
status.hidden = true;
row.append(status);
let live = true;
let pending = false;
let unknown = false;
let active = Boolean(appSession.check() && comment.isLikedByCurrentUser);
function apply(next: boolean, likesCount?: number): void {
active = next;
button.classList.toggle('game-details__like--active', next);
button.setAttribute('aria-pressed', String(next));
if (likesCount !== undefined) count.textContent = String(likesCount);
button.setAttribute(
'aria-label',
`${next ? 'Unlike' : 'Like'} comment, ${count.textContent} likes`
);
}
apply(active, comment.likesCount);
const unsubscribe = appSession.subscribe((session) => {
if (!session) {
apply(false);
unknown = false;
button.disabled = pending;
status.hidden = true;
}
});
async function click(): Promise<void> {
if (pending || unknown || !ensureActiveSession()) return;
const session = appSession.check()!;
pending = true;
button.disabled = true;
button.setAttribute('aria-busy', 'true');
status.hidden = false;
status.textContent = 'Updating like…';
try {
const result = await toggleCommentLike(comment.commentId, session);
const current = appSession.check();
if (
!live ||
current?.email !== session.email ||
current.authenticatedAt !== session.authenticatedAt
)
return;
apply(result.active, result.likesCount);
status.hidden = true;
showSnackbar(result.active ? 'Comment liked.' : 'Comment like removed.', 'success');
} catch (error) {
if (!live || appSession.check()?.authenticatedAt !== session.authenticatedAt) return;
unknown = !(error instanceof MutationFailure) || error.kind === 'unknown';
const message = unknown
? 'Like result is unknown. Refresh comments before trying again.'
: 'Comment like was rejected. Please try again.';
status.textContent = message;
status.hidden = false;
if (unknown) {
const retry = document.createElement('button');
retry.type = 'button';
retry.className = 'game-details__retry';
retry.textContent = 'Refresh comments';
// Reconciliation reads personalized state; it never repeats the ambiguous toggle.
retry.addEventListener('click', () => void refresh());
status.append(retry);
}
showSnackbar(message, 'error');
} finally {
pending = false;
if (live) {
button.disabled = unknown;
button.removeAttribute('aria-busy');
apply(appSession.check() ? active : false);
}
}
}
button.addEventListener('click', click);
return () => {
live = false;
unsubscribe();
button.removeEventListener('click', click);
status.remove();
};
}
14 changes: 11 additions & 3 deletions src/components/GameComments.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { relativeTime } from '../utils/relativeTime';
import favoriteIcon from '../assets/games/favorite.svg';
import { showSnackbar } from './Snackbar';
import { commenterInitial, createCommentAvatarColors } from '../utils/commentAvatar';
import { initializeCommentLike } from './CommentLikeControl';
import { appSession } from '../utils/session';

function commentMarkup(comment: GameComment, color: string): string {
Expand All @@ -21,10 +22,10 @@ function commentMarkup(comment: GameComment, color: string): string {
</time>
</div>
<p class="game-details__comment-text"></p>
<span class="game-details__like" aria-label="${comment.likesCount} likes">
<button type="button" class="game-details__like" aria-label="Like comment">
<img src="${favoriteIcon}" width="16" height="16" alt="" />
<span>${comment.likesCount}</span>
</span>
<span data-comment-likes>${comment.likesCount}</span>
</button>
</div>
`;
}
Expand All @@ -37,10 +38,13 @@ export function loadGameComments(
let controller: AbortController | null = null;
let version = 0;
let recovering = false;
let cleanupLikes: Array<() => void> = [];
let email = appSession.check()?.email;

async function load(): Promise<void> {
const current = ++version;
cleanupLikes.forEach((cleanup) => cleanup());
cleanupLikes = [];
controller?.abort();
controller = new AbortController();
section.innerHTML = `
Expand Down Expand Up @@ -70,6 +74,9 @@ export function loadGameComments(
.forEach((element, index) => {
element.textContent = result.data[index].text;
});
section.querySelectorAll<HTMLElement>('.game-details__comment').forEach((row, index) => {
cleanupLikes.push(initializeCommentLike(row, result.data[index], load));
});
if (recovering) {
showSnackbar('Comments loaded successfully.', 'success');
recovering = false;
Expand Down Expand Up @@ -100,6 +107,7 @@ export function loadGameComments(
return Object.assign(
() => {
unsubscribe();
cleanupLikes.forEach((cleanup) => cleanup());
version += 1;
controller?.abort();
},
Expand Down