feat(notify): NotificationService with 4 rotating reward copies

Task 15 of the slice plan. Strategy §4.1 immediate-reward
toast. celebrate(noteId) deterministically picks one of the
4 reward copies via SHA-256(noteId)[0] % 4, then forwards to
the injected send() callback (which Task 30 wires to a real
electron Notification). Skips silently when isSupported() is
false (denied OS permission), and swallows send() errors so
that capture path never fails because of a notification quirk.

Verification: `npx vitest run tests/unit/NotificationService.test.ts`
3 passed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
altair823
2026-04-25 12:11:44 +09:00
parent 38a54a83b8
commit c9ccf6433f
2 changed files with 74 additions and 0 deletions

View File

@@ -0,0 +1,33 @@
import { createHash } from 'node:crypto';
export const REWARD_COPIES = [
'이 생각은 이제 Inkling이 들고 있습니다.',
'나중에 찾을 수 있게 보관했습니다.',
'방금 하나의 업무 기억을 구출했습니다.',
'기록 완료. 이제 잊어도 됩니다.'
] as const;
export interface NotificationDeps {
isSupported: () => boolean;
send: (body: string) => void;
}
export class NotificationService {
constructor(private deps: NotificationDeps) {}
celebrate(noteId: string): void {
if (!this.deps.isSupported()) return;
const idx = this.pick(noteId);
const body = REWARD_COPIES[idx]!;
try {
this.deps.send(body);
} catch {
// Swallow notification errors — capture must not fail because of toast.
}
}
private pick(noteId: string): number {
const hash = createHash('sha256').update(noteId).digest();
return hash[0]! % REWARD_COPIES.length;
}
}

View File

@@ -0,0 +1,41 @@
import { describe, it, expect } from 'vitest';
import { NotificationService, REWARD_COPIES } from '@main/services/NotificationService.js';
describe('NotificationService', () => {
it('rotates copy deterministically by noteId hash', () => {
const fired: string[] = [];
const svc = new NotificationService({
isSupported: () => true,
send: (body) => { fired.push(body); }
});
const id1 = '00000000-0000-7000-8000-000000000001';
svc.celebrate(id1);
svc.celebrate(id1);
expect(fired).toHaveLength(2);
expect(fired[0]).toBe(fired[1]);
expect(REWARD_COPIES).toContain(fired[0]);
});
it('different ids select different (eventually all 4)', () => {
const fired: string[] = [];
const svc = new NotificationService({
isSupported: () => true,
send: (body) => { fired.push(body); }
});
for (let i = 0; i < 32; i++) {
svc.celebrate(`id-${i}`);
}
const distinct = new Set(fired);
expect(distinct.size).toBe(REWARD_COPIES.length);
});
it('skips silently when notifications unsupported', () => {
const fired: string[] = [];
const svc = new NotificationService({
isSupported: () => false,
send: (body) => { fired.push(body); }
});
svc.celebrate('id-1');
expect(fired).toHaveLength(0);
});
});