Compare commits

...

9 commits

8 changed files with 598 additions and 21 deletions

View file

@ -131,6 +131,9 @@ intentionally unclaimed; the built-in health endpoint is `/up`.
- Run the focused PHPUnit test while developing. - Run the focused PHPUnit test while developing.
- Use `just backend-types-check` for Larastan and `just backend-test` for the - Use `just backend-types-check` for Larastan and `just backend-test` for the
full PHPUnit suite during iteration. full PHPUnit suite during iteration.
- The completion gate runs `backend-test-unit` and `backend-test-feature` as
concurrent processes. This is safe because each process owns its own
in-memory SQLite database.
- Fix failures caused by the change. Report unrelated baseline failures - Fix failures caused by the change. Report unrelated baseline failures
precisely rather than expanding scope silently. precisely rather than expanding scope silently.
- The shared `just test-all` command is the required completion gate. Focused - The shared `just test-all` command is the required completion gate. Focused

View file

@ -95,6 +95,10 @@ backend and does not proxy the frontend.
- Cypress is configured under `cypress/` and runs through `npm run test:e2e` - Cypress is configured under `cypress/` and runs through `npm run test:e2e`
or `just frontend-cypress-run`. or `just frontend-cypress-run`.
- Every Cypress spec must appear in exactly one `frontend_specs_*` group in
the root `justfile`. The completion gate runs those groups concurrently,
and its `cypress-spec-coverage` job rejects ungrouped, duplicate, or missing
specs. Keep `frontend-cypress-run` for whole-suite iteration.
- Prefer the cheapest test seam that proves the behavior. Cypress covers - Prefer the cheapest test seam that proves the behavior. Cypress covers
routing, browser forms, authentication flows, request wiring, and responsive routing, browser forms, authentication flows, request wiring, and responsive
behavior. behavior.

View file

@ -223,12 +223,16 @@ gate passes against that worktree:
3. Run the complete gate from the worktree root: 3. Run the complete gate from the worktree root:
```sh ```sh
direnv exec "$(git rev-parse --show-toplevel)" just test-all JUST_JOBS=4 direnv exec "$(git rev-parse --show-toplevel)" just test-all
``` ```
4. Do not hand-assemble a substitute from focused commands. `test-all` runs 4. Do not hand-assemble a substitute from focused commands. `test-all` runs
frontend format and lint checks, frontend type checking, Larastan, the one pool of 13 jobs: frontend format, lint, type, build, and Cypress
production build, PHPUnit, and Cypress in fail-fast order. groups; Larastan; the PHPUnit Unit and Feature suites; and the Cypress
spec coverage guard. Jobs report as they finish, failed output is buffered
and printed at the end, and every job runs even after another job fails.
`JUST_JOBS` limits concurrency and defaults to 4. Use 2 when resources are
constrained or 1 for a serial debugging run.
5. Everything must pass before completion. Report exact baseline or 5. Everything must pass before completion. Report exact baseline or
environmental failures rather than hiding them. environmental failures rather than hiding them.
6. If the stack was started only for validation, stop it when finished: 6. If the stack was started only for validation, stop it when finished:

View file

@ -0,0 +1,249 @@
<?php
namespace Database\Seeders;
use App\Element\CreateElementDto;
use App\Element\Element;
use App\Element\ElementRepository;
use App\Set\CreateSetDto;
use App\Set\CreateSetLevelDto;
use App\Set\Set;
use App\Set\SetLevel;
use App\Set\SetLevelRepository;
use App\Set\SetRepository;
use App\Shared\ValueObject\EmailAddress;
use App\User\UserRepository;
use Illuminate\Database\Seeder;
use RuntimeException;
class TanachSeeder extends Seeder
{
private const string SET_NAME = 'Tanach';
private const array LEVEL_KINDS = [
'chelek',
'sefer',
'perek',
];
private const array SEFARIM_BY_CHELEK = [
'Torah' => [
'Bereishis' => 50,
'Shemos' => 40,
'Vayikra' => 27,
'Bamidbar' => 36,
'Devarim' => 34,
],
"Nevi'im" => [
'Yehoshua' => 24,
'Shoftim' => 21,
'Shmuel Alef' => 31,
'Shmuel Beis' => 24,
'Melachim Alef' => 22,
'Melachim Beis' => 25,
'Yeshayahu' => 66,
'Yirmiyahu' => 52,
'Yechezkel' => 48,
'Hoshea' => 14,
'Yoel' => 4,
'Amos' => 9,
'Ovadiah' => 1,
'Yonah' => 4,
'Michah' => 7,
'Nachum' => 3,
'Chavakuk' => 3,
'Tzefaniah' => 3,
'Chaggai' => 2,
'Zechariah' => 14,
'Malachi' => 3,
],
'Kesuvim' => [
'Tehillim' => 150,
'Mishlei' => 31,
'Iyov' => 42,
'Shir HaShirim' => 8,
'Rus' => 4,
'Eichah' => 5,
'Koheles' => 12,
'Esther' => 10,
'Daniel' => 12,
'Ezra' => 10,
'Nechemiah' => 13,
'Divrei HaYamim Alef' => 29,
'Divrei HaYamim Beis' => 36,
],
];
public function run(): void
{
$this->call(UserSeeder::class);
$user = app(UserRepository::class)->findByEmail(
new EmailAddress(UserSeeder::EMAIL),
);
if ($user === null) {
throw new RuntimeException('seeded user not found');
}
$setRepository = app(SetRepository::class);
$tanach = $this->findTanach($setRepository);
if ($tanach === null) {
$tanach = $setRepository->create(new CreateSetDto(
name: self::SET_NAME,
creator: $user,
));
}
$levels = $this->seedLevels(
repository: app(SetLevelRepository::class),
tanach: $tanach,
);
$this->seedElements(
repository: app(ElementRepository::class),
tanach: $tanach,
levels: $levels,
);
}
private function findTanach(SetRepository $repository): ?Set
{
foreach ($repository->all() as $set) {
if ($set->getName() === self::SET_NAME) {
return $set;
}
}
return null;
}
/**
* @return array{
* chelek: SetLevel,
* sefer: SetLevel,
* perek: SetLevel
* }
*/
private function seedLevels(
SetLevelRepository $repository,
Set $tanach,
): array {
$levels = $repository->findBySet($tanach);
foreach ($levels as $depth => $level) {
$expectedKind = self::LEVEL_KINDS[$depth] ?? null;
if ($level->getKind() !== $expectedKind) {
throw new RuntimeException(
'Tanach levels do not match the expected hierarchy',
);
}
}
$missingKinds = array_slice(self::LEVEL_KINDS, count($levels));
foreach ($missingKinds as $kind) {
$levels[] = $repository->create(new CreateSetLevelDto(
set: $tanach,
kind: $kind,
));
}
return [
'chelek' => $this->findLevel($levels, 'chelek'),
'sefer' => $this->findLevel($levels, 'sefer'),
'perek' => $this->findLevel($levels, 'perek'),
];
}
/**
* @param list<SetLevel> $levels
*/
private function findLevel(array $levels, string $kind): SetLevel
{
foreach ($levels as $level) {
if ($level->getKind() === $kind) {
return $level;
}
}
throw new RuntimeException("Tanach {$kind} level not found");
}
/**
* @param array{
* chelek: SetLevel,
* sefer: SetLevel,
* perek: SetLevel
* } $levels
*/
private function seedElements(
ElementRepository $repository,
Set $tanach,
array $levels,
): void {
$chelakim = $repository->findTopLevelBySet($tanach);
foreach (self::SEFARIM_BY_CHELEK as $chelekName => $sefarim) {
$chelek = $this->findOrCreateElement(
repository: $repository,
siblings: $chelakim,
name: $chelekName,
level: $levels['chelek'],
parentElement: null,
);
$seededSefarim = $repository->findByParentElement($chelek);
foreach ($sefarim as $seferName => $perekCount) {
$sefer = $this->findOrCreateElement(
repository: $repository,
siblings: $seededSefarim,
name: $seferName,
level: $levels['sefer'],
parentElement: $chelek,
);
$perakim = $repository->findByParentElement($sefer);
for (
$perekNumber = 1;
$perekNumber <= $perekCount;
$perekNumber++
) {
$this->findOrCreateElement(
repository: $repository,
siblings: $perakim,
name: "Perek {$perekNumber}",
level: $levels['perek'],
parentElement: $sefer,
);
}
}
}
}
/**
* @param list<Element> $siblings
*/
private function findOrCreateElement(
ElementRepository $repository,
array &$siblings,
string $name,
SetLevel $level,
?Element $parentElement,
): Element {
foreach ($siblings as $sibling) {
if (
$sibling->getName() === $name
&& $sibling->getLevel()->getId() === $level->getId()
) {
return $sibling;
}
}
$element = $repository->create(new CreateElementDto(
name: $name,
level: $level,
parentElement: $parentElement,
));
$siblings[] = $element;
return $element;
}
}

View file

@ -0,0 +1,184 @@
<?php
namespace Tests\Feature\Database;
use App\Element\Element;
use App\Element\ElementRepository;
use App\Set\Set;
use App\Set\SetLevelRepository;
use App\Set\SetRepository;
use App\Shared\ValueObject\EmailAddress;
use App\User\UserRepository;
use Database\Seeders\TanachSeeder;
use Database\Seeders\UserSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class TanachSeederTest extends TestCase
{
use RefreshDatabase;
private const array SEFARIM_BY_CHELEK = [
'Torah' => [
'Bereishis' => 50,
'Shemos' => 40,
'Vayikra' => 27,
'Bamidbar' => 36,
'Devarim' => 34,
],
"Nevi'im" => [
'Yehoshua' => 24,
'Shoftim' => 21,
'Shmuel Alef' => 31,
'Shmuel Beis' => 24,
'Melachim Alef' => 22,
'Melachim Beis' => 25,
'Yeshayahu' => 66,
'Yirmiyahu' => 52,
'Yechezkel' => 48,
'Hoshea' => 14,
'Yoel' => 4,
'Amos' => 9,
'Ovadiah' => 1,
'Yonah' => 4,
'Michah' => 7,
'Nachum' => 3,
'Chavakuk' => 3,
'Tzefaniah' => 3,
'Chaggai' => 2,
'Zechariah' => 14,
'Malachi' => 3,
],
'Kesuvim' => [
'Tehillim' => 150,
'Mishlei' => 31,
'Iyov' => 42,
'Shir HaShirim' => 8,
'Rus' => 4,
'Eichah' => 5,
'Koheles' => 12,
'Esther' => 10,
'Daniel' => 12,
'Ezra' => 10,
'Nechemiah' => 13,
'Divrei HaYamim Alef' => 29,
'Divrei HaYamim Beis' => 36,
],
];
public function test_it_seeds_the_complete_tanach_idempotently(): void
{
$this->seed(TanachSeeder::class);
$this->seed(TanachSeeder::class);
$this->assertDatabaseCount('users', 1);
$this->assertDatabaseCount('sets', 1);
$this->assertDatabaseCount('set_levels', 3);
$this->assertDatabaseCount('elements', 971);
$user = app(UserRepository::class)->findByEmail(
new EmailAddress(UserSeeder::EMAIL),
);
$this->assertNotNull($user);
$tanach = $this->findTanach();
$this->assertSame(
UserSeeder::EMAIL,
$tanach->getCreator()->getEmail()->value(),
);
$levels = app(SetLevelRepository::class)->findBySet($tanach);
$this->assertSame(
['chelek', 'sefer', 'perek'],
array_map(function ($level): string {
return $level->getKind();
}, $levels),
);
$elementRepository = app(ElementRepository::class);
$chelakim = $elementRepository->findTopLevelBySet($tanach);
$this->assertElements(
elements: $chelakim,
expectedNames: array_keys(self::SEFARIM_BY_CHELEK),
expectedKind: 'chelek',
);
$seferCount = 0;
$perekCount = 0;
foreach ($chelakim as $chelek) {
$expectedSefarim = self::SEFARIM_BY_CHELEK[
$chelek->getName()
] ?? null;
$this->assertNotNull($expectedSefarim);
$sefarim = $elementRepository->findByParentElement($chelek);
$this->assertElements(
elements: $sefarim,
expectedNames: array_keys($expectedSefarim),
expectedKind: 'sefer',
);
$seferCount += count($sefarim);
foreach ($sefarim as $sefer) {
$expectedPerekCount = $expectedSefarim[
$sefer->getName()
] ?? null;
$this->assertNotNull($expectedPerekCount);
$expectedPerekNames = [];
for (
$perekNumber = 1;
$perekNumber <= $expectedPerekCount;
$perekNumber++
) {
$expectedPerekNames[] = "Perek {$perekNumber}";
}
$perakim = $elementRepository->findByParentElement($sefer);
$this->assertElements(
elements: $perakim,
expectedNames: $expectedPerekNames,
expectedKind: 'perek',
);
$perekCount += count($perakim);
}
}
$this->assertSame(39, $seferCount);
$this->assertSame(929, $perekCount);
}
private function findTanach(): Set
{
foreach (app(SetRepository::class)->all() as $set) {
if ($set->getName() === 'Tanach') {
return $set;
}
}
$this->fail('Tanach set was not seeded');
}
/**
* @param list<Element> $elements
* @param list<string> $expectedNames
*/
private function assertElements(
array $elements,
array $expectedNames,
string $expectedKind,
): void {
$this->assertSame(
$expectedNames,
array_map(function (Element $element): string {
return $element->getName();
}, $elements),
);
foreach ($elements as $index => $element) {
$this->assertSame($expectedKind, $element->getKind());
$this->assertSame($index + 1, $element->getPosition());
}
}
}

View file

@ -69,7 +69,11 @@ describe('set element layout', () => {
cy.location('pathname').should('equal', '/sets/41') cy.location('pathname').should('equal', '/sets/41')
cy.get('h1').should('have.text', 'Bible') cy.get('h1').should('have.text', 'Bible')
cy.contains('a', 'Back to sets').should('have.attr', 'href', '/dashboard') cy.contains('a', 'Back to dashboard').should(
'have.attr',
'href',
'/dashboard',
)
cy.get('ol[aria-label="Bible element layout"] > li').then(($nodes) => { cy.get('ol[aria-label="Bible element layout"] > li').then(($nodes) => {
expect([...$nodes].map((node) => node.dataset.elementId)).to.deep.equal([ expect([...$nodes].map((node) => node.dataset.elementId)).to.deep.equal([
'1', '1',

View file

@ -55,7 +55,7 @@ function toggleElement(elementId: number): void {
<section class="set-layout"> <section class="set-layout">
<RouterLink class="back-link" :to="{ name: 'dashboard' }"> <RouterLink class="back-link" :to="{ name: 'dashboard' }">
<span aria-hidden="true"></span> <span aria-hidden="true"></span>
Back to sets Back to dashboard
</RouterLink> </RouterLink>
<p v-if="loading" class="layout-state" role="status">Loading set layout...</p> <p v-if="loading" class="layout-state" role="status">Loading set layout...</p>

159
justfile
View file

@ -1,30 +1,88 @@
set shell := ["bash", "-c"] set shell := ["bash", "-c"]
# How many jobs the parallel gate runs at once. Lower this when another
# worktree stack is active, or use 1 for a serial debugging run.
jobs := env('JUST_JOBS', '4')
default: default:
@just --list @just --list
# Full completion gate. Start the worktree stack before running it because # Full completion gate. Every check runs in one bounded pool, with likely
# Cypress exercises the frontend and its backend wiring. # long Cypress groups scheduled first and shorter jobs filling free lanes.
# Start the worktree stack before running it because Cypress needs Vite.
test-all: test-all:
@echo "==> frontend format + lint checks" just par "gate" {{ jobs }} \
just frontend-format-check frontend-cypress-account frontend-cypress-session \
just frontend-lint-check frontend-cypress-sets backend-types-check \
@echo "==> frontend type check" frontend-cypress-scheduling frontend-build \
just frontend-type-check backend-test-feature frontend-cypress-today \
@echo "==> backend static analysis" frontend-lint-check frontend-type-check backend-test-unit \
just backend-types-check frontend-format-check cypress-spec-coverage
@echo "==> frontend production build"
just frontend-build # Run recipes concurrently, buffering each one's output to its own log.
@echo "==> backend tests" # Report jobs as they finish, then print every failed job's complete log.
just backend-test [private]
@echo "==> frontend Cypress tests" par label max +targets:
just frontend-cypress-run #!/usr/bin/env bash
set -uo pipefail
targets=({{ targets }})
logs=$(mktemp -d)
declare -A target_of started_at
failed=()
next=0
running=0
echo "==> {{ label }} (${#targets[@]} jobs, up to {{ max }} at a time)"
while (( next < ${#targets[@]} || running > 0 )); do
while (( next < ${#targets[@]} && running < {{ max }} )); do
target="${targets[next]}"
just "$target" > "$logs/$target.log" 2>&1 &
target_of[$!]="$target"
started_at[$!]=$SECONDS
next=$(( next + 1 ))
running=$(( running + 1 ))
done
wait -n -p finished
status=$?
running=$(( running - 1 ))
target="${target_of[$finished]}"
elapsed=$(( SECONDS - started_at[$finished] ))
if (( status == 0 )); then
printf ' ok %-28s %4ds\n' "$target" "$elapsed"
else
printf ' FAIL %-28s %4ds\n' "$target" "$elapsed"
failed+=("$target")
fi
done
if (( ${#failed[@]} == 0 )); then
rm -rf "$logs"
exit 0
fi
for target in "${failed[@]}"; do
printf '\n--- %s ---\n' "$target"
cat "$logs/$target.log"
done
printf '\n{{ label }} failed: %s\n' "${failed[*]}"
printf 'logs kept in %s\n' "$logs"
exit 1
# Backend # Backend
backend-test *args: backend-test *args:
cd backend && php artisan test {{ args }} cd backend && php artisan test {{ args }}
backend-test-unit *args:
cd backend && php artisan test --testsuite=Unit {{ args }}
backend-test-feature *args:
cd backend && php artisan test --testsuite=Feature {{ args }}
backend-types-check: backend-types-check:
cd backend && composer types:check cd backend && composer types:check
@ -53,3 +111,74 @@ frontend-build:
frontend-cypress-run: frontend-cypress-run:
cd frontend/website && npm run test:e2e cd frontend/website && npm run test:e2e
# Each Cypress spec must belong to exactly one feature group. The complete
# gate runs these groups concurrently; the whole-suite recipe above remains
# available for focused iteration.
frontend_specs_account := "confirm-email login signup"
frontend_specs_session := "guest-auth session-auth"
frontend_specs_sets := "set-layout sets-dashboard"
frontend_specs_scheduling := "set-scheduling"
frontend_specs_today := "today-assignments"
frontend-cypress-account:
just _cypress {{ frontend_specs_account }}
frontend-cypress-session:
just _cypress {{ frontend_specs_session }}
frontend-cypress-sets:
just _cypress {{ frontend_specs_sets }}
frontend-cypress-scheduling:
just _cypress {{ frontend_specs_scheduling }}
frontend-cypress-today:
just _cypress {{ frontend_specs_today }}
[private]
_cypress +names:
cd frontend/website && specs=$(for name in {{ names }}; do \
printf 'cypress/e2e/%s.cy.ts,' "$name"; done) && \
npm run test:e2e -- --spec "${specs%,}"
cypress-spec-coverage:
just _spec-coverage {{ frontend_specs_account }} \
{{ frontend_specs_session }} {{ frontend_specs_sets }} \
{{ frontend_specs_scheduling }} {{ frontend_specs_today }}
[private]
_spec-coverage +names:
#!/usr/bin/env bash
set -uo pipefail
cd frontend/website
listed=$(printf '%s\n' {{ names }} | sort)
on_disk=$(ls cypress/e2e/*.cy.ts | xargs -n1 basename \
| sed 's/\.cy\.ts$//' | sort)
duplicated=$(echo "$listed" | uniq -d)
unique=$(echo "$listed" | uniq)
ungrouped=$(comm -13 <(echo "$unique") <(echo "$on_disk"))
missing=$(comm -23 <(echo "$unique") <(echo "$on_disk"))
status=0
if [ -n "$ungrouped" ]; then
echo "in no group, so never runs in the gate:"
printf ' %s\n' $ungrouped
status=1
fi
if [ -n "$missing" ]; then
echo "listed in a group but not on disk:"
printf ' %s\n' $missing
status=1
fi
if [ -n "$duplicated" ]; then
echo "in more than one group, so runs twice:"
printf ' %s\n' $duplicated
status=1
fi
exit $status