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:
33
src/main/services/NotificationService.ts
Normal file
33
src/main/services/NotificationService.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
41
tests/unit/NotificationService.test.ts
Normal file
41
tests/unit/NotificationService.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user