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
64 changes: 32 additions & 32 deletions .github/workflows/e2e-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,10 @@ on:
branches: [main, development]
paths:
- "apps/expo/**"
- "!apps/expo/**/__tests__/**"
- "!apps/expo/**/*.test.ts"
- "!apps/expo/**/*.test.tsx"
- "!apps/expo/vitest.config.ts"
- ".maestro/**"
- ".github/workflows/e2e-tests.yml"
# Note: Using `pull_request` (not `pull_request_target`) so forked PRs get
Expand All @@ -14,6 +18,10 @@ on:
branches: [main, development]
paths:
- "apps/expo/**"
- "!apps/expo/**/__tests__/**"
- "!apps/expo/**/*.test.ts"
- "!apps/expo/**/*.test.tsx"
- "!apps/expo/vitest.config.ts"
- ".maestro/**"
- ".github/workflows/e2e-tests.yml"
workflow_dispatch:
Expand All @@ -32,12 +40,32 @@ env:
MAESTRO_CLI_NO_ANALYTICS: "true"

jobs:
e2e-gate:
name: Check E2E prerequisites
runs-on: ubuntu-latest
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository
outputs:
ready: ${{ steps.check.outputs.ready }}
steps:
- id: check
name: Verify E2E secrets are available
env:
E2E_TEST_EMAIL: ${{ secrets.E2E_TEST_EMAIL }}
NEON_DATABASE_URL: ${{ secrets.NEON_DEV_DATABASE_URL }}
run: |
if [ -n "$E2E_TEST_EMAIL" ] && [ -n "$NEON_DATABASE_URL" ]; then
echo "ready=true" >> "$GITHUB_OUTPUT"
else
echo "ready=false" >> "$GITHUB_OUTPUT"
echo "::notice::E2E secrets not configured — skipping E2E tests"
fi

ios-e2e:
name: iOS E2E Tests
needs: e2e-gate
if: needs.e2e-gate.outputs.ready == 'true'
runs-on: macos-15
timeout-minutes: 120
# Skip on forked PRs — secrets are not available in forks
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository

env:
# The E2E user is upserted into the dev DB by the seed step below,
Expand All @@ -46,20 +74,6 @@ jobs:
TEST_PASSWORD: ${{ secrets.E2E_TEST_PASSWORD }}

steps:
- name: Verify E2E secrets are configured
run: |
missing=()
[ -z "${TEST_EMAIL:-}" ] && missing+=("E2E_TEST_EMAIL")
[ -z "${TEST_PASSWORD:-}" ] && missing+=("E2E_TEST_PASSWORD")
[ -z "${NEON_DATABASE_URL:-}" ] && missing+=("NEON_DEV_DATABASE_URL")
if [ ${#missing[@]} -gt 0 ]; then
echo "::error::Required E2E secrets missing: ${missing[*]}"
echo "::error::Set them via: gh secret set <NAME> --repo PackRat-AI/PackRat"
exit 1
fi
env:
NEON_DATABASE_URL: ${{ secrets.NEON_DEV_DATABASE_URL }}

- name: Checkout repository
uses: actions/checkout@v6

Expand Down Expand Up @@ -269,10 +283,10 @@ jobs:

android-e2e:
name: Android E2E Tests
needs: e2e-gate
if: needs.e2e-gate.outputs.ready == 'true'
runs-on: ubuntu-latest
timeout-minutes: 120
# Skip on forked PRs — secrets are not available in forks
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository

env:
# The E2E user is upserted into the dev DB by the seed step below,
Expand All @@ -281,20 +295,6 @@ jobs:
TEST_PASSWORD: ${{ secrets.E2E_TEST_PASSWORD }}

steps:
- name: Verify E2E secrets are configured
run: |
missing=()
[ -z "${TEST_EMAIL:-}" ] && missing+=("E2E_TEST_EMAIL")
[ -z "${TEST_PASSWORD:-}" ] && missing+=("E2E_TEST_PASSWORD")
[ -z "${NEON_DATABASE_URL:-}" ] && missing+=("NEON_DEV_DATABASE_URL")
if [ ${#missing[@]} -gt 0 ]; then
echo "::error::Required E2E secrets missing: ${missing[*]}"
echo "::error::Set them via: gh secret set <NAME> --repo PackRat-AI/PackRat"
exit 1
fi
env:
NEON_DATABASE_URL: ${{ secrets.NEON_DEV_DATABASE_URL }}

- name: Free disk space on runner
# Gradle builds of this RN app fail with OOM / no-space on stock
# ubuntu-latest. Prune large preinstalled toolchains we don't use.
Expand Down
48 changes: 30 additions & 18 deletions .github/workflows/web-e2e-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,10 @@ on:
branches: [main, development]
paths:
- "apps/expo/**"
- "!apps/expo/**/__tests__/**"
- "!apps/expo/**/*.test.ts"
- "!apps/expo/**/*.test.tsx"
- "!apps/expo/vitest.config.ts"
- ".github/workflows/web-e2e-tests.yml"
# Note: Using `pull_request` (not `pull_request_target`) so forked PRs get
# CI feedback on their own code. Secrets are unavailable for forks, so
Expand All @@ -13,6 +17,10 @@ on:
branches: [main, development]
paths:
- "apps/expo/**"
- "!apps/expo/**/__tests__/**"
- "!apps/expo/**/*.test.ts"
- "!apps/expo/**/*.test.tsx"
- "!apps/expo/vitest.config.ts"
- ".github/workflows/web-e2e-tests.yml"
workflow_dispatch:

Expand All @@ -24,12 +32,32 @@ permissions:
contents: read

jobs:
e2e-gate:
name: Check E2E prerequisites
runs-on: ubuntu-latest
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository
outputs:
ready: ${{ steps.check.outputs.ready }}
steps:
- id: check
name: Verify E2E secrets are available
env:
E2E_TEST_EMAIL: ${{ secrets.E2E_TEST_EMAIL }}
NEON_DATABASE_URL: ${{ secrets.NEON_DEV_DATABASE_URL }}
run: |
if [ -n "$E2E_TEST_EMAIL" ] && [ -n "$NEON_DATABASE_URL" ]; then
echo "ready=true" >> "$GITHUB_OUTPUT"
else
echo "ready=false" >> "$GITHUB_OUTPUT"
echo "::notice::E2E secrets not configured — skipping Web E2E tests"
fi

web-e2e:
name: Web E2E Tests
needs: e2e-gate
if: needs.e2e-gate.outputs.ready == 'true'
runs-on: ubuntu-latest
timeout-minutes: 30
# Skip on forked PRs — secrets are not available in forks
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository

env:
# The E2E user is upserted into the dev DB by the seed step below,
Expand All @@ -38,22 +66,6 @@ jobs:
TEST_PASSWORD: ${{ secrets.E2E_TEST_PASSWORD }}

steps:
- name: Verify E2E secrets are configured
run: |
missing=()
[ -z "${TEST_EMAIL:-}" ] && missing+=("E2E_TEST_EMAIL")
[ -z "${TEST_PASSWORD:-}" ] && missing+=("E2E_TEST_PASSWORD")
[ -z "${NEON_DATABASE_URL:-}" ] && missing+=("NEON_DEV_DATABASE_URL")
[ -z "${EXPO_PUBLIC_API_URL:-}" ] && missing+=("EXPO_PUBLIC_API_URL")
if [ ${#missing[@]} -gt 0 ]; then
echo "::error::Required E2E secrets missing: ${missing[*]}"
echo "::error::Set them via: gh secret set <NAME> --repo PackRat-AI/PackRat"
exit 1
fi
env:
NEON_DATABASE_URL: ${{ secrets.NEON_DEV_DATABASE_URL }}
EXPO_PUBLIC_API_URL: ${{ secrets.EXPO_PUBLIC_API_URL }}

- name: Checkout repository
uses: actions/checkout@v6

Expand Down
131 changes: 131 additions & 0 deletions apps/expo/features/packs/utils/__tests__/computeCategories.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
import type { Pack, PackItem } from 'expo-app/features/packs/types';
import { describe, expect, it, vi } from 'vitest';
import { computeCategorySummaries } from '../computeCategories';

vi.mock('expo-app/features/auth/store', () => ({
userStore: {
preferredWeightUnit: {
peek: vi.fn().mockReturnValue('g'),
},
},
}));

function makeItem(
overrides: Partial<PackItem> & Pick<PackItem, 'weight' | 'weightUnit'>,
): PackItem {
return {
id: 'item-1',
name: 'Test Item',
quantity: 1,
category: 'Shelter',
consumable: false,
worn: false,
packId: 'pack-1',
deleted: false,
isAIGenerated: false,
...overrides,
};
}

function makePack(items: PackItem[]): Pack {
return {
id: 'pack-1',
name: 'Test Pack',
category: 'hiking',
isPublic: false,
deleted: false,
items,
baseWeight: 0,
totalWeight: 0,
};
}

describe('computeCategorySummaries', () => {
it('returns empty array for a pack with no items', () => {
expect(computeCategorySummaries(makePack([]))).toEqual([]);
});

it('groups items under the correct category name', () => {
const items = [
makeItem({ id: 'i1', weight: 200, weightUnit: 'g', category: 'Shelter' }),
makeItem({ id: 'i2', weight: 300, weightUnit: 'g', category: 'Food' }),
];
const result = computeCategorySummaries(makePack(items));
expect(result).toHaveLength(2);
const names = result.map((c) => c.name);
expect(names).toContain('Shelter');
expect(names).toContain('Food');
});

it('falls back to "Other" for empty category string', () => {
const items = [makeItem({ id: 'i1', weight: 100, weightUnit: 'g', category: '' })];
const result = computeCategorySummaries(makePack(items));
expect(result[0]?.name).toBe('Other');
});

it('falls back to "Other" for whitespace-only category', () => {
const items = [makeItem({ id: 'i1', weight: 100, weightUnit: 'g', category: ' ' })];
const result = computeCategorySummaries(makePack(items));
expect(result[0]?.name).toBe('Other');
});

it('computes weight in preferred unit (grams)', () => {
const items = [makeItem({ weight: 500, weightUnit: 'g', category: 'Pack' })];
const result = computeCategorySummaries(makePack(items));
expect(result[0]?.weight).toBe(500);
});

it('converts weight units before computing (kg → g)', () => {
const items = [makeItem({ weight: 1, weightUnit: 'kg', category: 'Pack' })];
const result = computeCategorySummaries(makePack(items));
expect(result[0]?.weight).toBe(1000);
});

it('multiplies weight by quantity', () => {
const items = [makeItem({ weight: 100, weightUnit: 'g', quantity: 3, category: 'Food' })];
const result = computeCategorySummaries(makePack(items));
expect(result[0]?.weight).toBe(300);
});

it('sets percentage to 100 for a single-category pack', () => {
const items = [makeItem({ weight: 300, weightUnit: 'g', category: 'Electronics' })];
const result = computeCategorySummaries(makePack(items));
expect(result[0]?.percentage).toBe(100);
});

it('splits percentage evenly across equal-weight categories', () => {
const items = [
makeItem({ id: 'i1', weight: 500, weightUnit: 'g', category: 'Shelter' }),
makeItem({ id: 'i2', weight: 500, weightUnit: 'g', category: 'Food' }),
];
const result = computeCategorySummaries(makePack(items));
for (const cat of result) {
expect(cat.percentage).toBe(50);
}
});

it('counts item rows (not total quantity) in each category', () => {
const items = [
makeItem({ id: 'i1', weight: 100, weightUnit: 'g', quantity: 5, category: 'Food' }),
makeItem({ id: 'i2', weight: 200, weightUnit: 'g', quantity: 2, category: 'Food' }),
];
const result = computeCategorySummaries(makePack(items));
expect(result[0]?.items).toBe(2);
});

it('merges multiple items in the same category', () => {
const items = [
makeItem({ id: 'i1', weight: 300, weightUnit: 'g', category: 'Shelter' }),
makeItem({ id: 'i2', weight: 200, weightUnit: 'g', category: 'Shelter' }),
];
const result = computeCategorySummaries(makePack(items));
expect(result).toHaveLength(1);
expect(result[0]?.weight).toBe(500);
});

it('sets percentage to 0 when total weight is zero', () => {
const items = [makeItem({ weight: 0, weightUnit: 'g', category: 'Empty' })];
const result = computeCategorySummaries(makePack(items));
expect(result[0]?.percentage).toBe(0);
});
});
Loading
Loading