feat: Beute L3–L5 (3D-Szene, Umbauen, Durchsicht, offline) und Diktat (T7 L1)

Beute L3 Runden 2+3 und Review-Fixes:
- Glas-Holo ohne warmes Leuchten, Wanderboden-Fuss, Fokusflug mit
  Geist-Ansicht, Inhalte im Fokus, Rähmchen mit Art-Farbe beim Anheben
- Hinweis-Geometrie je Zarge nur einmal je Layout gebaut; Drehen mit
  Pointer-Capture, endet auch bei Loslassen ausserhalb (rig-pointer.ts)
- Tiefenunschärfe ab 55 fps, Leistungsprobe bei jeder Bewegungsstufe,
  Context-Loss-Listener aufgeräumt, Trefferboxen als skalierte Einheitsbox

Beute L4 Umbauen (/colonies/[id]/hive): Entwurf mit Basis-Schnappschuss
beim ersten Schritt, Rückgängig/Wiederholen, Hinweis bei Serverstand-
Änderung, Konfliktblatt.

Beute L5:
- Durchsicht-Abschnitt „Beute“: Aufbau-Änderungen in der Durchsicht,
  Blatt-Formulare stoppen das Submit-Bubbling durch Portale (sonst
  speicherte „Speichern“ im Blatt die ganze Durchsicht), Fokus nach
  Rückgängig, Folgevorschläge beim Speichern
- Offline: Beute im Offline-Paket, Warteschlange pending-hive-changes
  (DB v5), Abarbeiten in Reihenfolge, keine Online-Änderung überholt
  wartende, Hinweis bei abgelehnter Änderung mit „Warteschlange öffnen“

T7 L1 Diktat: Spracherkennungs-Kapsel (Web Speech API), Einwilligung
standardmässig aus, Diktat-Knopf an Freitextfeldern, Seite
„Sprache & Datenschutz“; axe-core als exakte devDependency.

Backend: NotificationsService liest Empfänger und schreibt in einer
Transaktion mit FOR KEY SHARE (paralleles Löschen eines Benutzers
führte zu P2003 und verzögerten Vorschlägen; Ursache der wackeligen
Endtests). Zwei e2e-Prüfungen gegen geteilten DB-Zustand entschärft.

Gates: Frontend lint, typecheck, 2740 Tests, build; Backend lint,
typecheck, 4266 Unit-Tests, build; parallele e2e 3x grün (978).
Browser-Prüfung 1280/390 px, Tastatur, offline, reduced motion.

Refs #19 #20 #21 #22 #27 #32

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
Nick SzaunigandClaude Opus 5.5 committed 2026-10-16 09:12:04 +02:00
1 parent ab45d6e24e
commit 1a57de8573
123 files changed
+7639 -355

No files matched your search

@@ -32,6 +32,7 @@ describe('NotificationsService', () => {
user: { findMany: jest.Mock };
$connect: jest.Mock;
$transaction: jest.Mock;
$queryRaw: jest.Mock;
};
beforeEach(() => {
@@ -52,10 +53,19 @@ describe('NotificationsService', () => {
// A transaction client lacks $connect; the root client has it.
$connect: jest.fn(),
$transaction: jest.fn(),
// The FOR KEY SHARE lock on the recipients: every user still exists.
$queryRaw: jest.fn().mockImplementation(() => {
const found = prisma.user.findMany.mock.results.at(-1);
return Promise.resolve(found ? (found.value as unknown) : []);
}),
};
prisma.$transaction.mockImplementation((work: (tx: unknown) => unknown) =>
Promise.resolve(work(prisma)),
);
prisma.$transaction.mockImplementation((work: (tx: unknown) => unknown) => {
// A transaction client has no $connect; the code tells the two apart.
const txClient = Object.fromEntries(
Object.entries(prisma).filter(([key]) => key !== '$connect'),
);
return Promise.resolve(work(txClient));
});
held = new Set();
service = new NotificationsService(
prisma as unknown as PrismaService,
@@ -78,6 +88,28 @@ describe('NotificationsService', () => {
void service.record({ priority: 'INFO', category: 'SYSTEM', message: 'm' });
});
describe('recipient locking', () => {
it('locks the recipients in the write transaction and skips a user deleted meanwhile', async () => {
prisma.user.findMany.mockResolvedValue([{ id: 'u1' }, { id: 'u2' }]);
prisma.$queryRaw.mockResolvedValue([{ id: 'u1' }]);
const rows = await service.recordForUsers(['u1', 'u2'], {
priority: 'INFO',
category: 'SYSTEM',
message: 'm',
});
expect(prisma.$transaction).toHaveBeenCalledTimes(1);
expect(prisma.$queryRaw).toHaveBeenCalledTimes(1);
expect(
(prisma.$queryRaw.mock.calls[0] as [{ sql: string }])[0].sql,
).toContain('FOR KEY SHARE');
expect(rows.map((row) => (row as { userId: string }).userId)).toEqual([
'u1',
]);
});
});
describe('record', () => {
it('writes one row for an eligible recipient and returns it', async () => {
prisma.user.findMany.mockResolvedValue([{ id: 'u1' }]);
@@ -138,6 +170,7 @@ describe('NotificationsService', () => {
it('writes through the transaction client when one is given', async () => {
const tx = {
user: { findMany: jest.fn().mockResolvedValue([{ id: 'u1' }]) },
$queryRaw: jest.fn().mockResolvedValue([{ id: 'u1' }]),
notification: {
createManyAndReturn: jest.fn().mockResolvedValue([{ id: 'n' }]),
},
@@ -180,7 +213,10 @@ describe('NotificationsService', () => {
const rows = await service.recordForUsers(['u1', 'u2'], base);
expect(planner.planIn).toHaveBeenCalledTimes(1);
expect(planner.planIn).toHaveBeenCalledWith(prisma, rows);
expect(planner.planIn).toHaveBeenCalledWith(
expect.objectContaining({ notification: prisma.notification }),
rows,
);
expect(
prisma.notification.createManyAndReturn.mock.invocationCallOrder[0],
).toBeLessThan(planner.planIn.mock.invocationCallOrder[0]);
@@ -189,6 +225,7 @@ describe('NotificationsService', () => {
it('plans with the caller transaction and opens none of its own', async () => {
const tx = {
user: { findMany: jest.fn().mockResolvedValue([{ id: 'u1' }]) },
$queryRaw: jest.fn().mockResolvedValue([{ id: 'u1' }]),
notification: {
createManyAndReturn: jest.fn().mockResolvedValue([{ id: 'n' }]),
},
@@ -316,6 +353,7 @@ describe('NotificationsService', () => {
it('reads and writes through the transaction client when one is given', async () => {
const tx = {
user: { findMany: jest.fn().mockResolvedValue([{ id: 'u1' }]) },
$queryRaw: jest.fn().mockResolvedValue([{ id: 'u1' }]),
notification: {
createManyAndReturn: jest.fn().mockResolvedValue([{ id: 'n1' }]),
},
@@ -342,6 +380,7 @@ describe('NotificationsService', () => {
let calls: string[];
let tx: {
$executeRaw: jest.Mock;
$queryRaw: jest.Mock;
user: { findMany: jest.Mock };
notification: { findMany: jest.Mock; createManyAndReturn: jest.Mock };
};
@@ -353,6 +392,11 @@ describe('NotificationsService', () => {
calls.push('lock');
return Promise.resolve(1);
}),
// The key-share lock keeps every user the read just returned.
$queryRaw: jest.fn().mockImplementation(() => {
const read = tx.user.findMany.mock.results.at(-1);
return Promise.resolve(read ? (read.value as unknown) : []);
}),
user: {
// Like the database: the id filter narrows the three eligible users.
findMany: jest