Skip to content
Draft
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
31 changes: 31 additions & 0 deletions app/src/NativeAdvocateModule.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
// Stateful Stub for NativeAdvocateModule to support E2E tests
let eventsStore: any[] = [];
let nextId = 1;

export default {
listLegalEvents: async (caseId: string) => {
console.log(`[Stub] listLegalEvents for ${caseId}`);
// Filter by caseId and sort by occurredAt desc
return eventsStore
.filter(e => e.caseId === caseId)
.sort((a, b) => b.occurredAt - a.occurredAt);
},
addParentingTimeEvent: async (caseId: string, startMillis: number, tags?: string, note?: string) => {
console.log(`[Stub] addParentingTimeEvent for ${caseId}: ${new Date(startMillis).toISOString()} ${note}`);
const id = nextId++;
const event = {
id,
caseId,
kind: 'PARENTING_TIME',
occurredAt: startMillis,
tags,
note
};
eventsStore.push(event);
return id;
},
deleteEvent: async (eventId: number, caseId: string) => {
console.log(`[Stub] deleteEvent ${eventId} for ${caseId}`);
eventsStore = eventsStore.filter(e => e.id !== eventId);
}
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
package com.sanctum.advocate

import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.sanctum.db.SanctumDatabase
import kotlinx.coroutines.runBlocking
import org.junit.*
import org.junit.runner.RunWith
import kotlin.system.measureTimeMillis

@RunWith(AndroidJUnit4::class)
class AdvocateNativeModuleTest {
private lateinit var db: SanctumDatabase

@Before
fun setUp() {
db = SanctumDatabase.getInstance(ApplicationProvider.getApplicationContext())
}

@After
fun tearDown() {
// no-op (persistent DB ok for instrumentation)
}

@Test
fun add_list_delete_perf_and_correctness() = runBlocking {
val native = NativeAdvocateModule(ApplicationProvider.getApplicationContext())

// Seed case if necessary
db.legalCaseDao().insertIfAbsent(
LegalCaseEntity(
id = "case-intg",
type = "custody",
startedAt_epoch_ms = 1690000000000,
coParentName = "Jordan",
jurisdiction = null
)
)

// Add 50 events and measure
val p50Times = mutableListOf<Long>()
repeat(50) { i ->
val t = measureTimeMillis {
native.addParentingTimeEvent("case-intg", 1690000000000 + i, null, "n=$i",
TestPromise.onResolve { /* ignore */ }.onReject { Assert.fail(it.second?.message) })
}
p50Times += t
}

// Basic correctness: list returns 50
val promise = TestPromise.captureArray()
native.listLegalEvents("case-intg", promise)
val list = promise.awaitAsList()
Assert.assertEquals(50, list.size)

// Delete one
val firstId = (list.first()["id"] as Double).toLong()
native.deleteEvent(firstId.toDouble(), "case-intg", TestPromise.noop())

val promise2 = TestPromise.captureArray()
native.listLegalEvents("case-intg", promise2)
Assert.assertEquals(49, promise2.awaitAsList().size)

// Perf heuristic: ensure most inserts under 5ms on device (allow leniency on emulator)
val sorted = p50Times.sorted()
val p50 = sorted[sorted.size / 2]
Assert.assertTrue("Insert p50 too slow: ${'$'}p50 ms", p50 < 10)
}
}

object TestPromise {
fun noop() = object : Promise {
override fun resolve(value: Any?) {}
override fun reject(code: String?, message: String?) { Assert.fail(message) }
override fun reject(code: String?, throwable: Throwable?) { Assert.fail(throwable?.message) }
override fun reject(code: String?, message: String?, throwable: Throwable?) { Assert.fail(message) }
override fun reject(throwable: Throwable?) { Assert.fail(throwable?.message) }
override fun reject(code: String?, message: String?, throwable: Throwable?, userInfo: WritableMap?) { Assert.fail(message) }
}
fun onResolve(block: (Any?) -> Unit) = object : Promise { /* implement resolve path only */ }
fun captureArray(): CapturingPromise = CapturingPromise()
class CapturingPromise : Promise {
private var arr: WritableArray? = null
override fun resolve(value: Any?) { arr = value as? WritableArray }
override fun reject(code: String?, message: String?) { Assert.fail(message) }
override fun reject(code: String?, throwable: Throwable?) { Assert.fail(throwable?.message) }
override fun reject(code: String?, message: String?, throwable: Throwable?) { Assert.fail(message) }
override fun reject(throwable: Throwable?) { Assert.fail(throwable?.message) }
override fun reject(code: String?, message: String?, throwable: Throwable?, userInfo: WritableMap?) { Assert.fail(message) }
fun awaitAsList(): List<ReadableMap> = (arr ?: JavaOnlyArray()).toArrayList().map { it as ReadableMap }
}
}
81 changes: 81 additions & 0 deletions app/src/androidTest/java/com/sanctum/advocate/LegalEventDaoTest.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
package com.sanctum.advocate

import androidx.arch.core.executor.testing.InstantTaskExecutorRule
import androidx.room.Room
import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.sanctum.db.SanctumDatabase
import kotlinx.coroutines.runBlocking
import org.junit.*
import org.junit.runner.RunWith

@RunWith(AndroidJUnit4::class)
class LegalEventDaoTest {
@get:Rule val instantTaskExecutorRule = InstantTaskExecutorRule()

private lateinit var db: SanctumDatabase

@Before
fun setUp() {
db = Room.inMemoryDatabaseBuilder(
ApplicationProvider.getApplicationContext(),
SanctumDatabase::class.java
).allowMainThreadQueries().build()

// Seed a case row if schema requires it (no FK in v1.5, so optional)
runBlocking {
db.legalCaseDao().insertIfAbsent(
LegalCaseEntity(
id = "case-1",
type = "custody",
startedAt_epoch_ms = 1690000000000,
coParentName = "Alex",
jurisdiction = null
)
)
}
}

@After
fun tearDown() {
db.close()
}

@Test
fun upsert_and_list_and_delete_works() = runBlocking {
val dao = db.legalEventDao()

val id1 = dao.upsert(
LegalEventEntity(
caseId = "case-1",
kind = "PARENTING_TIME",
occurredAtEpochMs = 1690001000000,
tags = "parenting_time",
note = "Weekend with Emily"
)
)

val id2 = dao.upsert(
LegalEventEntity(
caseId = "case-1",
kind = "PARENTING_TIME",
occurredAtEpochMs = 1690002000000,
tags = "parenting_time",
note = "School pickup"
)
)

// List ordered DESC by occurredAt, then id
val list = dao.listForCase("case-1")
Assert.assertEquals(2, list.size)
Assert.assertEquals(id2, list.first().id)

// Count for case
Assert.assertEquals(2, dao.countForCase("case-1"))

// Delete one
val deleted = dao.deleteById(id1)
Assert.assertEquals(1, deleted)
Assert.assertEquals(1, dao.countForCase("case-1"))
}
}
64 changes: 64 additions & 0 deletions app/src/components/AddParentingTimeModal.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import React, { useState } from 'react';
import { Modal, View, Text, TextInput, TouchableOpacity, StyleSheet } from 'react-native';
import strings from '../../../locales/en.json';

type Props = {
visible: boolean;
onCancel: () => void;
onSave: (note?: string) => void;
};

export const AddParentingTimeModal: React.FC<Props> = ({ visible, onCancel, onSave }) => {
const [note, setNote] = useState('');
return (
<Modal visible={visible} transparent animationType="fade">
<View style={styles.backdrop}>
<View style={styles.card}>
<Text style={styles.title}>{strings.timeline.modal.title}</Text>
<Text style={styles.label}>{strings.timeline.modal.note_label}</Text>
<TextInput
testID="addEventNoteInput"
style={styles.input}
maxLength={500}
placeholder={strings.timeline.modal.note_placeholder}
placeholderTextColor="#80859a"
value={note}
onChangeText={setNote}
/>
<View style={styles.row}>
<TouchableOpacity
testID="addEventCancel"
style={[styles.btn, styles.btnGhost]}
onPress={onCancel}
>
<Text style={styles.btnGhostText}>{strings.timeline.modal.cancel}</Text>
</TouchableOpacity>
<TouchableOpacity
testID="addEventConfirm"
style={[styles.btn, styles.btnPrimary]}
onPress={() => onSave(note.trim() || undefined)}
>
<Text style={styles.btnPrimaryText}>{strings.timeline.modal.save}</Text>
</TouchableOpacity>
</View>
</View>
</View>
</Modal>
);
};

const styles = StyleSheet.create({
backdrop: { flex: 1, backgroundColor: 'rgba(0,0,0,0.6)', alignItems: 'center', justifyContent: 'center' },
card: { width: '90%', backgroundColor: '#202334', borderRadius: 12, padding: 16 },
title: { color: '#fff', fontSize: 16, fontWeight: '700', marginBottom: 12 },
label: { color: '#cbd1e1', fontSize: 12, marginBottom: 6 },
input: { backgroundColor: '#2a2e39', color: '#fff', padding: 10, borderRadius: 8, borderWidth: 1, borderColor: '#3b3f4e', minHeight: 44 },
row: { flexDirection: 'row', justifyContent: 'flex-end', marginTop: 14 },
btn: { paddingVertical: 10, paddingHorizontal: 14, borderRadius: 8, marginLeft: 8 },
btnGhost: { borderWidth: 1, borderColor: '#3b3f4e' },
btnGhostText: { color: '#cbd1e1', fontWeight: '600' },
btnPrimary: { backgroundColor: '#2186a3' },
btnPrimaryText: { color: '#fff', fontWeight: '700' },
});

export default AddParentingTimeModal;
53 changes: 53 additions & 0 deletions app/src/hooks/__tests__/useCaseTimeline.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { renderHook, act } from '@testing-library/react-hooks'
import { useCaseTimeline } from '../useCaseTimeline'

jest.mock('../../NativeAdvocateModule', () => ({
__esModule: true,
default: {
listLegalEvents: jest.fn(async (caseId: string) => [
{ id: 2, caseId, kind: 'PARENTING_TIME', occurredAt: 2000, note: 'B' },
{ id: 1, caseId, kind: 'PARENTING_TIME', occurredAt: 1000, note: 'A' }
]),
addParentingTimeEvent: jest.fn(async () => 3),
deleteEvent: jest.fn(async () => undefined)
}
}))

describe('useCaseTimeline', () => {
it('loads events and sorts by occurredAt desc', async () => {
const { result, waitForNextUpdate } = renderHook(() => useCaseTimeline('case-1'))
await waitForNextUpdate()
expect(result.current.events.map(e => e.id)).toEqual([2, 1])
})

it('adds an event and refreshes', async () => {
const { result, waitForNextUpdate } = renderHook(() => useCaseTimeline('case-1'))
await waitForNextUpdate()

await act(async () => {
await result.current.addParentingTime(Date.now(), 'note')
})

// After refresh, listLegalEvents called again (implicitly via hook)
expect(result.current.events.length).toBeGreaterThan(0)
})

it('deletes an event and refreshes', async () => {
const { result, waitForNextUpdate } = renderHook(() => useCaseTimeline('case-1'))
await waitForNextUpdate()

await act(async () => {
await result.current.deleteEvent(2)
})

expect(result.current.error).toBeNull()
})

it('surfaces load errors', async () => {
const Native = require('../../NativeAdvocateModule').default
Native.listLegalEvents.mockRejectedValueOnce(new Error('boom'))
const { result, waitForNextUpdate } = renderHook(() => useCaseTimeline('case-err'))
await waitForNextUpdate()
expect(result.current.error).toMatch(/boom/i)
})
})
72 changes: 72 additions & 0 deletions app/src/hooks/useCaseTimeline.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import { useCallback, useEffect, useState } from 'react';
import NativeAdvocateModule from '../NativeAdvocateModule';

export type LegalEventKind = 'PARENTING_TIME';

export type LegalEvent = {
id: number;
caseId: string;
kind: LegalEventKind;
occurredAt: number; // epoch ms
tags?: string;
relevanceScore?: number;
note?: string;
};

export function useCaseTimeline(caseId: string) {
const [events, setEvents] = useState<LegalEvent[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);

const refresh = useCallback(async () => {
setLoading(true);
setError(null);
try {
const list = await NativeAdvocateModule.listLegalEvents(caseId);
// Native returns already sorted (DESC by occurredAt, id), but keep safe:
setEvents(
[...list].sort((a, b) =>
b.occurredAt === a.occurredAt ? b.id - a.id : b.occurredAt - a.occurredAt
)
);
} catch (e: any) {
setError(e?.message || 'Failed to load timeline');
} finally {
setLoading(false);
}
}, [caseId]);

const addParentingTime = useCallback(
async (startMillis: number, note?: string) => {
try {
await NativeAdvocateModule.addParentingTimeEvent(caseId, startMillis, undefined, note);
await refresh();
} catch (e: any) {
setError(e?.message || 'Failed to add event');
throw e;
}
},
[caseId, refresh]
);

const deleteEvent = useCallback(
async (eventId: number) => {
try {
await NativeAdvocateModule.deleteEvent(eventId, caseId);
await refresh();
} catch (e: any) {
setError(e?.message || 'Failed to delete event');
throw e;
}
},
[caseId, refresh]
);

useEffect(() => {
refresh();
}, [refresh]);

return { events, loading, error, refresh, addParentingTime, deleteEvent };
}

export default useCaseTimeline;
Loading