This one costs money.
AI redoes the requirements↔tests map — about $5–$10 on Sonnet. The current map is replaced.
The plain ↺ is free.
Review
PR description on GitHub ↗
Closes #25.
Opened for the review demo, do not merge.
4 implementation assumptions · 14 open review issues · 25 auto-fixed in VS Code
Round I · while coding
Where the ticket was ambiguous: the reading the coder chose.
Read the other way: tie-breakers follow the clicked direction, one backward index walk
Why 60%: the spec says owners within a city are ordered by name, read as A→Z (openspec/changes/paginate-owners-grid/specs/owner-list/spec.md:56↗). Nobody asked how a descending city should order its own names; a reviewer reading "descending" as the whole row reversed would be equally right.
+118return Sort.by(direction, "city").and(Sort.by("firstName", "lastName", "id"));
Read the other way: delete the owner in the After hook with the pet
Why 65%: matches add-owner.spec.ts, which leaves its owners on purpose; the After hook already deletes the pet. Pulls down: each run adds one more pet-less owner to the dev database.
+20const {headers: created} = await axios.post(`${API_BASE}/owners`, {
+21 firstName: 'Ada', lastName: `Daterange${Date.now()}`,
+22 address: '110 Analytical Engine Way', city: 'London', telephone: '6085551023',
+23}, {timeout: 10_000});
Read the other way: one footer row outside #ownersTable, paginator shown conditionally inside it
Why 70%: the acceptance tests select #ownersTable mat-paginator, so the paginator must stay inside the table block; a second button in the empty state keeps that.
+16<div *ngIf="page?.totalElements === 0 || errorMessage" class="owners-empty-actions">
Read the other way: take the first pet from GET /api/pets
Why 75%: AGENTS.md promises owner 1 = Kevin McCallister to tests, and the DSL fails loudly if he loses his pet. It now books on Kevin every run rather than on whichever owner listed first.
+17const owner = await new ApiClient().fetchOwner(SEEDED_OWNER_WITH_PET);
Round II · code review
Left in the code, each with its reason. They stay open until you agree or disagree.
Reviewer: POST /api/owners/1/pets/{another owner's pet}/visits books it and texts owner 1; the MCP path checks ownership.
predates the audited range (visit-date commit b23c6d3a); worth its own ticket.
213Pet pet = petRepository.findById(petId).orElseThrow();
Reviewer: 5190fd3c deleted the firstPet method line from the rewrite input, so the test's Java no longer parses.
the human's own live-coding edit, committed as found at their request.
49return owner.getPets().get(0);
Reviewer: create_visit only requires a future date, and moving a pet's birthDate past its visits is never checked.
the visit-date rule is b23c6d3a's, outside this change set.
69requireFutureDate(visitDate);
Reviewer: requireValidVisitDate returns on a null date, and VisitMapper overwrites the entity's default with null.
the visit-date rule is b23c6d3a's, outside this change set.
90if (visitDate == null) {
91 return;
92}
Reviewer: Hibernate Validator's own failures (HV000030) now answer 400 at WARN instead of 500 at ERROR.
the handler came with b23c6d3a, outside this change set.
+59return badRequest(List.of(ex.getMessage()), ex.getMessage(), request);
Reviewer: the form's max date uses the browser's timezone, the server's LocalDate.now() the JVM's; they disagree near midnight.
b23c6d3a's visit form, outside this change set.
26readonly maxVisitDate = moment().add(1, 'year').format('YYYY-MM-DD');
Reviewer: bookVisit loads the Pet only to validate it, then saves the Visit with the mapper's id-only stub.
b23c6d3a's booking path, outside this change set.
75petRepository.findById(visitDto.getPetId()).orElseThrow()
76 .requireValidVisitDate(visitDto.getDate(), LocalDate.now());
Reviewer: the grid shows pet names, yet each page loads and serializes all visits — one extra batch query and payload.
a slim row is a contract change, a non-goal: openspec/changes/paginate-owners-grid/design.md:72↗
27PetDto petDto = new PetDto()
28 .setVisits(visitMapper.toVisitsDto(pet.getVisitsSortedByDate()))
29 .setName(pet.getName())
30 .setBirthDate(pet.getBirthDate())
31 .setType(toPetTypeDto(pet.getType()))
32 .setId(pet.getId());
Reviewer: Page re-runs count(*) on each click although the total did not change.
accepted at 10 ms for 100k rows: openspec/changes/paginate-owners-grid/design.md:146↗
+107Page<Owner> owners = ownerRepository.findByLastNameStartingWith(lastName,
+108 PageRequest.of(page, size, toSort(sort)));
Reviewer: showFirstLastButtons jumps to OFFSET 99990, which walks the whole index.
keyset paging is a non-goal, 24 ms measured: openspec/changes/paginate-owners-grid/design.md:71↗
50</table>
Reviewer: city DESC with names ASC needs an incremental sort over each city's group.
measured at 0.27 ms on 100k rows, openspec/changes/paginate-owners-grid/design.md:134↗
+118return Sort.by(direction, "city").and(Sort.by("firstName", "lastName", "id"));
Reviewer: Pageable, @PageableDefault and max-page-size exist for this.
rejected for the sort whitelist and 400-not-clamp: openspec/changes/paginate-owners-grid/design.md:88↗
+101public OwnerPageDto listOwners(
+102 @RequestParam(defaultValue = "") String lastName,
+103 @RequestParam(defaultValue = "0") @Min(0) @Max(MAX_PAGE) int page,
+104 @RequestParam(defaultValue = "10") @Min(1) @Max(MAX_PAGE_SIZE) int size,
+105 @RequestParam(defaultValue = "name,asc") @Pattern(regexp = "(name|city)(,(asc|desc))?",
+106 message = "must be name or city, optionally followed by ,asc or ,desc") String sort) {
+107 Page<Owner> owners = ownerRepository.findByLastNameStartingWith(lastName,
+108 PageRequest.of(page, size, toSort(sort)));
+109 return ownerMapper.toOwnerPageDto(owners);
+110}
Reviewer: the default-page test already proves first-name order, so the Beatrix/Harry test adds nothing.
each test pins one spec scenario: openspec/changes/paginate-owners-grid/specs/owner-list/spec.md:50↗
138void sortByName_putsFirstNamesFirst() throws Exception {
139 owner("Harry", "Pagerpotter", "London");
140 owner("Beatrix", "Pagerpotter", "Near Sawrey");
141
142 OwnerPageDto page = list("?lastName=Pagerpotter&sort=name,asc");
143
144 assertThat(page.content()).extracting(OwnerDto::getFirstName).containsExactly("Beatrix", "Harry");
145}
Reviewer: two specs each build their own OwnerPage fixture.
two uses; the reviewer itself said not worth it until a third.
42schemas: [NO_ERRORS_SCHEMA],
+43imports: [CommonModule, FormsModule, NoopAnimationsModule, OwnersModule, RouterTestingModule],
44providers: [
+45 {provide: OwnerService, useClass: OwnerServiceStub},
46 {provide: ActivatedRoute, useClass: ActivatedRouteStub}
47]
Round III · fixing the review
Fixed by the review in 4 fix commits: 5190fd3c, 3ef70228, 1bdb4c61, d01c3776. Diffs against 0b99e29c, the implementation commit. 3 commits carry a Review-Points: trailer; the page reads them as one round of fixes, from the implementation to the last of them.
Reviewer: the button moved inside #ownersTable, rendered only when totalElements > 0; on no match, an error or an empty clinic there was no way to /owners/add.
| 13 | 13 | |
| 14 | 14 | <div *ngIf="errorMessage" id="ownersError" class="alert alert-danger">Could not load the owners: {{ errorMessage }}</div> |
| 15 | 15 | <div *ngIf="page?.totalElements === 0" id="noOwners">No owners with last name starting with "{{ query.lastName }}"</div> |
| 16 | + <div *ngIf="page?.totalElements === 0 || errorMessage" class="owners-empty-actions"> | |
| 17 | + <button class="btn btn-default" routerLink="/owners/add">Add Owner</button> | |
| 18 | + </div> | |
| 16 | 19 | <div class="table-responsive" id="ownersTable" *ngIf="page && page.totalElements > 0"> |
| 17 | - <table class="table table-striped" | |
| 18 | - matSort [matSortActive]="sortColumn" [matSortDirection]="sortDirection" matSortDisableClear | |
| 19 | - (matSortChange)="onSort($event)"> | |
| 20 | + <table class="table table-striped"> | |
| 20 | 21 | <thead> |
| 21 | 22 | <tr> |
| 22 | - <th mat-sort-header="name">Name</th> | |
| 23 | - <th>Address</th> | |
| 24 | - <th mat-sort-header="city">City</th> | |
| 25 | - <th>Telephone</th> | |
| 26 | - <th>Pets</th> | |
| 23 | + <th class="col-name sortable" [attr.aria-sort]="ariaSort('name')"> | |
| 24 | + <button type="button" class="sort-button" (click)="sortBy('name')"> | |
| 25 | + Name<span class="sort-indicator" [ngClass]="indicator('name')"></span> | |
| 26 | + </button> | |
| 27 | + </th> | |
| 28 | + <th class="col-address">Address</th> | |
| 29 | + <th class="col-city sortable" [attr.aria-sort]="ariaSort('city')"> | |
| 30 | + <button type="button" class="sort-button" (click)="sortBy('city')"> | |
| 31 | + City<span class="sort-indicator" [ngClass]="indicator('city')"></span> | |
| 32 | + </button> | |
| 33 | + </th> | |
| 34 | + <th class="col-telephone">Telephone</th> | |
| 35 | + <th class="col-pets">Pets</th> | |
| 27 | 36 | </tr> |
| 28 | 37 | </thead> |
| 29 | 38 |
| describe('OwnerListComponent', () => { | ||
| 124 | 115 | }); |
| 125 | 116 | |
| 126 | 117 | it('a new sort goes back to the first page', () => { |
| 127 | - url.set({page: '3'}); | |
| 118 | + route.setQueryParams({page: '3'}); | |
| 128 | 119 | fixture.detectChanges(); |
| 129 | 120 | |
| 130 | - component.onSort({active: 'city', direction: 'desc'}); | |
| 121 | + component.sortBy('city'); | |
| 131 | 122 | |
| 132 | - expect(navigatedTo()).toEqual({sort: 'city,desc'}); | |
| 123 | + expect(navigatedTo()).toEqual({sort: 'city,asc'}); | |
| 133 | 124 | }); |
| 134 | 125 | |
| 135 | 126 | it('leaves the URL clean when everything is back to its default', () => { |
| 136 | - url.set({page: '2', sort: 'city,asc'}); | |
| 127 | + route.setQueryParams({page: '2', sort: 'city,asc'}); | |
| 137 | 128 | fixture.detectChanges(); |
| 138 | 129 | |
| 139 | - component.onSort({active: 'name', direction: 'asc'}); | |
| 130 | + component.sortBy('name'); | |
| 140 | 131 | |
| 141 | 132 | expect(navigatedTo()).toEqual({}); |
| 142 | 133 | }); |
| 143 | 134 | |
| 144 | - it('only Name and City can be sorted', () => { | |
| 135 | + it('clicking the sorted column flips its direction', () => { | |
| 136 | + route.setQueryParams({sort: 'city,asc'}); | |
| 137 | + fixture.detectChanges(); | |
| 138 | + | |
| 139 | + component.sortBy('city'); | |
| 140 | + | |
| 141 | + expect(navigatedTo()).toEqual({sort: 'city,desc'}); | |
| 142 | + }); | |
| 143 | + | |
| 144 | + it('only Name and City can be sorted, and the active one says which way', () => { | |
| 145 | + route.setQueryParams({sort: 'city,desc'}); | |
| 145 | 146 | fixture.detectChanges(); |
| 146 | 147 | |
| 147 | - const sortable = fixture.debugElement.queryAll(By.css('th[mat-sort-header]')) | |
| 148 | - .map((th) => th.attributes['mat-sort-header']); | |
| 149 | - expect(sortable).toEqual(['name', 'city']); | |
| 148 | + const sortable = fixture.debugElement.queryAll(By.css('th.sortable')); | |
| 149 | + expect(sortable.map((th) => th.nativeElement.textContent.trim())).toEqual(['Name', 'City']); | |
| 150 | + expect(sortable.map((th) => th.nativeElement.getAttribute('aria-sort'))).toEqual([null, 'descending']); | |
| 150 | 151 | }); |
| 151 | 152 | |
| 152 | 153 | it('says no owner matched when the search finds none', () => { |
| 153 | 154 | getOwnersPage.and.returnValue(of(aPage([]))); |
| 154 | - url.set({lastName: 'Zzzz'}); | |
| 155 | + route.setQueryParams({lastName: 'Zzzz'}); | |
| 155 | 156 | fixture.detectChanges(); |
| 156 | 157 | |
| 157 | 158 | expect(text('#noOwners')).toBe('No owners with last name starting with "Zzzz"'); |
| 158 | 159 | expect(fixture.debugElement.query(By.css('#ownersTable'))).toBeNull(); |
| 159 | 160 | }); |
| 160 | 161 | |
| 162 | + // An owner nobody found is exactly the one about to be added. | |
| 163 | + it('still offers Add Owner when the search finds none', () => { | |
| 164 | + getOwnersPage.and.returnValue(of(aPage([]))); | |
| 165 | + route.setQueryParams({lastName: 'Zzzz'}); | |
| 166 | + fixture.detectChanges(); | |
| 167 | + | |
| 168 | + expect(text('button[routerLink="/owners/add"]')).toBe('Add Owner'); | |
| 169 | + }); | |
| 170 | + | |
| 171 | + it('searching again for the same name asks again, though the URL does not change', () => { | |
| 172 | + route.setQueryParams({lastName: 'Pot'}); | |
| 173 | + fixture.detectChanges(); | |
| 174 | + getOwnersPage.calls.reset(); | |
| 175 | + | |
| 176 | + component.lastName = 'Pot'; | |
| 177 | + component.search(); | |
| 178 | + | |
| 179 | + expect(navigate).not.toHaveBeenCalled(); | |
| 180 | + expect(getOwnersPage).toHaveBeenCalledWith({lastName: 'Pot', page: 0, size: 10, sort: 'name,asc'}); | |
| 181 | + }); | |
| 182 | + | |
| 161 | 183 | it('shows a failure as an error, not as "no owners"', () => { |
| 162 | 184 | getOwnersPage.and.returnValue(throwError('server returned code 500')); |
| 163 | 185 | fixture.detectChanges(); |
Fix: the empty and error states show their own Add Owner under the message.
Reviewer: ?page=abc hit the catch-all handler as a type mismatch; ?page=30000000&size=100 overflowed Spring Data's int offset. Both logged at ERROR as 500.
| import jakarta.validation.constraints.Pattern; | ||
| 57 | 57 | public class OwnerRestController { |
| 58 | 58 | |
| 59 | 59 | static final int MAX_PAGE_SIZE = 100; |
| 60 | + // page * size must stay an int: Spring Data's offset overflows past it | |
| 61 | + static final int MAX_PAGE = Integer.MAX_VALUE / MAX_PAGE_SIZE; | |
| 60 | 62 | |
| 61 | 63 | private final OwnerRepository ownerRepository; |
| 62 | 64 | private final PetRepository petRepository; |
| class OwnerListTest { | ||
| 120 | 120 | .andExpect(jsonPath("$.errors[0]").value(containsString("page"))); |
| 121 | 121 | } |
| 122 | 122 | |
| 123 | + @Test | |
| 124 | + void pageThatIsNotANumber_isRejected() throws Exception { | |
| 125 | + mockMvc.perform(get("/api/owners?page=abc")) | |
| 126 | + .andExpect(status().isBadRequest()) | |
| 127 | + .andExpect(jsonPath("$.errors[0]").value(containsString("page"))); | |
| 128 | + } | |
| 129 | + | |
| 130 | + @Test | |
| 131 | + void pageWhoseOffsetWouldOverflow_isRejected() throws Exception { | |
| 132 | + mockMvc.perform(get("/api/owners?page=30000000&size=100")) | |
| 133 | + .andExpect(status().isBadRequest()) | |
| 134 | + .andExpect(jsonPath("$.errors[0]").value(containsString("page"))); | |
| 135 | + } | |
| 136 | + | |
| 123 | 137 | @Test |
| 124 | 138 | void sortByName_putsFirstNamesFirst() throws Exception { |
| 125 | 139 | owner("Harry", "Pagerpotter", "London"); |
| public class OwnerRestController { | ||
| 98 | 100 | @GetMapping(produces = "application/json") |
| 99 | 101 | public OwnerPageDto listOwners( |
| 100 | 102 | @RequestParam(defaultValue = "") String lastName, |
| 101 | - @RequestParam(defaultValue = "0") @Min(0) int page, | |
| 103 | + @RequestParam(defaultValue = "0") @Min(0) @Max(MAX_PAGE) int page, | |
| 102 | 104 | @RequestParam(defaultValue = "10") @Min(1) @Max(MAX_PAGE_SIZE) int size, |
| 103 | 105 | @RequestParam(defaultValue = "name,asc") @Pattern(regexp = "(name|city)(,(asc|desc))?", |
| 104 | 106 | message = "must be name or city, optionally followed by ,asc or ,desc") String sort) { |
| 105 | 107 | Page<Owner> owners = ownerRepository.findByLastNameStartingWith(lastName, |
| 106 | 108 | PageRequest.of(page, size, toSort(sort))); |
| 107 | - return new OwnerPageDto(ownerMapper.toOwnerDtoCollection(owners.getContent()), | |
| 108 | - owners.getTotalElements(), owners.getTotalPages(), owners.getNumber(), owners.getSize()); | |
| 109 | + return ownerMapper.toOwnerPageDto(owners); | |
| 109 | 110 | } |
| 110 | 111 | |
| 111 | 112 | // The direction applies to the clicked column only; the tie-breakers stay ascending and end |
| public class ExceptionControllerAdvice { | ||
| 46 | 47 | } |
| 47 | 48 | |
| 48 | 49 | @ExceptionHandler(ConstraintViolationException.class) |
| 49 | - @ResponseStatus(HttpStatus.BAD_REQUEST) | |
| 50 | + @ResponseStatus(HttpStatus.BAD_REQUEST) // springdoc documents the 400 on every operation from this | |
| 50 | 51 | public ResponseEntity<ProblemDetail> handleConstraintViolation(ConstraintViolationException ex, |
| 51 | 52 | HttpServletRequest request) { |
| 52 | - List<String> errors = ValidationErrorExtractor.extract(ex); | |
| 53 | - log.warn("Validation failed: {}", errors); | |
| 54 | - ProblemDetail pd = buildProblemDetail("Validation Error", | |
| 55 | - "Validation failed for request. See 'errors' for details.", HttpStatus.BAD_REQUEST, request); | |
| 56 | - pd.setProperty("errors", errors); | |
| 57 | - return ResponseEntity.badRequest().body(pd); | |
| 53 | + return badRequest(ValidationErrorExtractor.extract(ex), SEE_ERRORS, request); | |
| 58 | 54 | } |
| 59 | 55 | |
| 60 | 56 | @ExceptionHandler(ValidationException.class) |
| 61 | 57 | public ResponseEntity<ProblemDetail> handleValidationException(ValidationException ex, |
| 62 | 58 | HttpServletRequest request) { |
| 63 | - log.warn("Validation failed: {}", ex.getMessage()); | |
| 64 | - ProblemDetail pd = buildProblemDetail("Validation Error", ex.getMessage(), HttpStatus.BAD_REQUEST, request); | |
| 65 | - pd.setProperty("errors", List.of(ex.getMessage())); | |
| 66 | - return ResponseEntity.badRequest().body(pd); | |
| 59 | + return badRequest(List.of(ex.getMessage()), ex.getMessage(), request); | |
| 67 | 60 | } |
| 68 | 61 | |
| 69 | 62 | @ExceptionHandler(MethodArgumentNotValidException.class) |
| 70 | 63 | @ResponseStatus(HttpStatus.BAD_REQUEST) |
| 71 | 64 | public ResponseEntity<ProblemDetail> handleMethodArgumentNotValidException(MethodArgumentNotValidException ex, |
| 72 | 65 | HttpServletRequest request) { |
| 73 | - BindingResult bindingResult = ex.getBindingResult(); | |
| 74 | - // reuse ValidationErrorExtractor style: build list of readable messages | |
| 75 | - List<String> errors = ValidationErrorFieldExtractor.extract(bindingResult); | |
| 66 | + return badRequest(ValidationErrorFieldExtractor.extract(ex.getBindingResult()), SEE_ERRORS, request); | |
| 67 | + } | |
| 68 | + | |
| 69 | + // A query or path parameter that does not parse (?page=abc) is the client's mistake, not a crash | |
| 70 | + @ExceptionHandler(MethodArgumentTypeMismatchException.class) | |
| 71 | + public ResponseEntity<ProblemDetail> handleTypeMismatch(MethodArgumentTypeMismatchException ex, | |
| 72 | + HttpServletRequest request) { | |
| 73 | + String type = ex.getRequiredType() == null ? "value" : ex.getRequiredType().getSimpleName(); | |
| 74 | + return badRequest(List.of(ex.getName() + " must be a " + type + " (value: " + ex.getValue() + ")"), | |
| 75 | + SEE_ERRORS, request); | |
| 76 | + } | |
| 77 | + | |
| 78 | + private ResponseEntity<ProblemDetail> badRequest(List<String> errors, String detail, HttpServletRequest request) { | |
| 76 | 79 | log.warn("Validation failed: {}", errors); |
| 77 | - ProblemDetail pd = buildProblemDetail("Validation Error", | |
| 78 | - "Validation failed for request. See 'errors' for details.", HttpStatus.BAD_REQUEST, request); | |
| 80 | + ProblemDetail pd = buildProblemDetail("Validation Error", detail, HttpStatus.BAD_REQUEST, request); | |
| 79 | 81 | pd.setProperty("errors", errors); |
| 80 | 82 | return ResponseEntity.badRequest().body(pd); |
| 81 | 83 | } |
Fix: type mismatches map to 400 for every endpoint; page is capped at Integer.MAX_VALUE / MAX_PAGE_SIZE.
Reviewer: search() only navigates; an identical URL emits nothing, so a repeated search showed stale results where the old grid re-fetched.
| export class OwnerListComponent implements OnInit, OnDestroy { | ||
| 60 | 56 | } |
| 61 | 57 | |
| 62 | 58 | search() { |
| 63 | - this.navigate({lastName: this.lastName, page: 1}); | |
| 59 | + const searched = {...this.query, lastName: this.lastName, page: 1}; | |
| 60 | + if (JSON.stringify(toUrl(searched)) === JSON.stringify(toUrl(this.query))) { | |
| 61 | + this.reload.next(searched); | |
| 62 | + return; | |
| 63 | + } | |
| 64 | + this.navigate(searched); | |
| 64 | 65 | } |
| 65 | 66 | |
| 66 | 67 | onPage(event: PageEvent) { |
| export class OwnerListComponent implements OnInit, OnDestroy { | ||
| 33 | 28 | page: OwnerPage | undefined; |
| 34 | 29 | errorMessage: string | undefined; |
| 35 | 30 | private load: Subscription; |
| 31 | + // Asks again for the query already in the URL: navigating to an identical URL emits nothing. | |
| 32 | + private readonly reload = new Subject<OwnerListQuery>(); | |
| 36 | 33 | |
| 37 | - constructor(private router: Router, private route: ActivatedRoute, private ownerService: OwnerService) { | |
| 34 | + constructor(private readonly router: Router, private readonly route: ActivatedRoute, | |
| 35 | + private readonly ownerService: OwnerService) { | |
| 38 | 36 | } |
| 39 | 37 | |
| 40 | 38 | ngOnInit() { |
| 41 | - this.load = this.route.queryParamMap.pipe( | |
| 42 | - map(fromUrl), | |
| 39 | + this.load = merge(this.route.queryParamMap.pipe(map(fromUrl)), this.reload).pipe( | |
| 43 | 40 | tap((query) => { |
| 44 | 41 | this.query = query; |
| 45 | 42 | this.lastName = query.lastName; |
| 46 | 43 | }), |
| 47 | 44 | // switchMap: only the latest query may answer, however late an earlier one comes back |
| 48 | - switchMap((query) => this.ownerService.getOwnersPage( | |
| 49 | - {lastName: query.lastName, page: query.page - 1, size: query.size, sort: query.sort}).pipe( | |
| 45 | + switchMap((query) => this.ownerService.getOwnersPage({...query, page: query.page - 1}).pipe( | |
| 50 | 46 | catchError((error) => { |
| 51 | 47 | this.errorMessage = String(error); |
| 52 | 48 | this.page = undefined; |
owner-list.component.spec.ts: diff shown under Add Owner vanished when a search matched nobody.
Fix: an unchanged query is pushed through a reload subject merged into the URL stream.
Reviewer: the batch-fetch test enabled statistics on the shared SessionFactory and never disabled them, so later tests reusing the context paid for and inherited them.
| class OwnerListTest { | ||
| 201 | 215 | Statistics statistics = entityManager.getEntityManagerFactory().unwrap(SessionFactory.class).getStatistics(); |
| 202 | 216 | statistics.setStatisticsEnabled(true); |
| 203 | 217 | statistics.clear(); |
| 204 | - | |
| 205 | - list("?size=20"); | |
| 206 | - | |
| 207 | - // page + count + one batch of pets + one batch of visits (+ pet types) | |
| 208 | - assertThat(statistics.getPrepareStatementCount()).isLessThanOrEqualTo(5); | |
| 218 | + try { | |
| 219 | + list("?size=20"); | |
| 220 | + | |
| 221 | + // page + count + one batch of pets + one batch of visits (+ pet types) | |
| 222 | + assertThat(statistics.getPrepareStatementCount()).isLessThanOrEqualTo(5); | |
| 223 | + } finally { | |
| 224 | + statistics.setStatisticsEnabled(false); // the SessionFactory outlives this test | |
| 225 | + } | |
| 209 | 226 | } |
| 210 | 227 | |
| 211 | 228 | private Owner owner(String firstName, String lastName, String city) { |
Fix: try/finally switches them off.
Reviewer: owners.reduce is not a function failed both visit-date-range scenarios in their Background once the list became a page.
| Given('today is {word}', async function (this: PlaywrightWorld, today: string) { | ||
| 13 | 13 | await this.page.clock.setFixedTime(new Date(`${today}T10:00:00`)); |
| 14 | 14 | }); |
| 15 | 15 | |
| 16 | -/** A pet of its own, on the last owner — the seed has none born that day, and no other scenario looks there. */ | |
| 16 | +const idFrom = (location: string) => Number(location.split('/').pop()); | |
| 17 | + | |
| 18 | +/** A pet of its own, on an owner of its own — no other scenario looks there. */ | |
| 17 | 19 | Given('a pet born on {word}', async function (this: PlaywrightWorld, birthDate: string) { |
| 18 | - const {data: owners} = await axios.get(`${API_BASE}/owners`, {timeout: 10_000}); | |
| 19 | - const owner = owners.reduce((a: any, b: any) => (a.id > b.id ? a : b)); | |
| 20 | + const {headers: created} = await axios.post(`${API_BASE}/owners`, { | |
| 21 | + firstName: 'Ada', lastName: `Daterange${Date.now()}`, | |
| 22 | + address: '110 Analytical Engine Way', city: 'London', telephone: '6085551023', | |
| 23 | + }, {timeout: 10_000}); | |
| 24 | + const ownerId = idFrom(created.location); | |
| 20 | 25 | const name = `Born ${birthDate} ${Date.now()}`; |
| 21 | - const {headers} = await axios.post(`${API_BASE}/owners/${owner.id}/pets`, | |
| 26 | + const {headers} = await axios.post(`${API_BASE}/owners/${ownerId}/pets`, | |
| 22 | 27 | {name, birthDate, type: {id: 1, name: 'cat'}}, {timeout: 10_000}); |
| 23 | - this.ownerId = owner.id; | |
| 24 | - this.createdPetId = this.petId = Number(headers.location.split('/').pop()); | |
| 28 | + this.ownerId = ownerId; | |
| 29 | + this.createdPetId = this.petId = idFrom(headers.location); | |
| 25 | 30 | this.petName = name; |
| 26 | 31 | }); |
| 27 | 32 |
Fix: the scenario creates its own owner and puts the pet on it.
Reviewer: the committed diagram still drew "List owners" with an array payload; only the browser suites' diagrams had been re-traced.
Fix: re-traced with petclinic-backend/run-tests-with-tracing.sh.
+16Test -> Backend: List one page of owners\nGET /api/owners
CI: "Validation failed: {}", "Validation Error" and "errors" were duplicated across the 400 handlers; the new type-mismatch handler made it a fourth copy.
ExceptionControllerAdvice.java: diff shown under Bad page input answered 500 instead of 400.
Fix: one badRequest helper builds every validation 400.
Reviewer: about 80 lines (#nameGroup, .owners-pagination, .owner-search-label, …) matched nothing in the template, beside the live rules.
| 1 | -#ownersByLastName | |
| 2 | -{ | |
| 3 | -display:none; | |
| 4 | -} | |
| 5 | - | |
| 6 | -#nameGroup { | |
| 7 | - display: flex; | |
| 8 | - align-items: center; | |
| 9 | - gap: 16px; | |
| 10 | - margin-left: 0; | |
| 11 | - margin-right: 0; | |
| 12 | - padding-left: 0; | |
| 13 | -} | |
| 14 | - | |
| 15 | -#addressGroup { | |
| 16 | - display: flex; | |
| 17 | - align-items: center; | |
| 18 | - gap: 16px; | |
| 19 | - margin-left: 0; | |
| 20 | - margin-right: 0; | |
| 21 | - padding-left: 0; | |
| 22 | -} | |
| 23 | - | |
| 24 | -.owner-search-label { | |
| 25 | - margin: 0; | |
| 26 | - text-align: left; | |
| 27 | - min-width: 60px; | |
| 28 | -} | |
| 29 | - | |
| 30 | -.owner-search-input { | |
| 31 | - flex: 1; | |
| 32 | -} | |
| 33 | - | |
| 34 | -.owners-controls { | |
| 35 | - display: flex; | |
| 36 | - align-items: center; | |
| 37 | - justify-content: space-between; | |
| 38 | - gap: 16px; | |
| 39 | - margin-bottom: 16px; | |
| 40 | -} | |
| 41 | - | |
| 42 | -.owners-pagination { | |
| 43 | - display: flex; | |
| 44 | - align-items: center; | |
| 45 | - gap: 12px; | |
| 46 | -} | |
| 47 | - | |
| 48 | -.owners-page-size { | |
| 1 | +/* One row as wide as the table: label, stretching input, search button */ | |
| 2 | +.owner-search-row { | |
| 49 | 3 | display: flex; |
| 50 | 4 | align-items: center; |
| 51 | - gap: 8px; | |
| 5 | + gap: 15px; | |
| 6 | + margin-bottom: 15px; | |
| 52 | 7 | } |
| 53 | 8 | |
| 54 | -.owners-page-size .form-control { | |
| 55 | - width: auto; | |
| 9 | +.owner-search-row label { | |
| 10 | + margin-bottom: 0; | |
| 11 | + white-space: nowrap; | |
| 56 | 12 | } |
| 57 | 13 | |
| 58 | -@media (max-width: 576px) { | |
| 59 | - #nameGroup { | |
| 60 | - flex-direction: column; | |
| 61 | - align-items: flex-start; | |
| 62 | - gap: 8px; | |
| 63 | - } | |
| 64 | - | |
| 65 | - #addressGroup { | |
| 66 | - flex-direction: column; | |
| 67 | - align-items: flex-start; | |
| 68 | - gap: 8px; | |
| 69 | - } | |
| 70 | - | |
| 71 | - .owner-search-input { | |
| 72 | - width: 100%; | |
| 73 | - } | |
| 74 | - | |
| 75 | - .owners-controls { | |
| 76 | - flex-direction: column; | |
| 77 | - align-items: flex-start; | |
| 78 | - } | |
| 14 | +.owner-search-row button { | |
| 15 | + flex-shrink: 0; | |
| 79 | 16 | } |
| 80 | 17 | |
| 81 | 18 | /* Fixed widths: auto layout sized the columns to each page's rows, so the headers jumped on every sort. */ |
| 82 | 19 | #ownersTable table { |
| 83 | 20 | table-layout: fixed; |
| 21 | + margin-bottom: 0; | |
| 84 | 22 | } |
| 85 | 23 | |
| 86 | -#ownersTable th:nth-child(1) { width: 22%; } | |
| 87 | -#ownersTable th:nth-child(2) { width: 26%; } | |
| 88 | -#ownersTable th:nth-child(3) { width: 16%; } | |
| 89 | -#ownersTable th:nth-child(4) { width: 18%; } | |
| 90 | -#ownersTable th:nth-child(5) { width: 18%; } | |
| 24 | +.col-name { width: 22%; } | |
| 25 | +.col-address { width: 26%; } | |
| 26 | +.col-city { width: 16%; } | |
| 27 | +.col-telephone { width: 18%; } | |
| 28 | +.col-pets { width: 18%; } | |
| 91 | 29 | |
| 92 | 30 | #ownersTable td { |
| 93 | 31 | overflow-wrap: anywhere; |
| 94 | 32 | } |
| 95 | 33 | |
| 96 | -/* Paginator reads as the table's footer: no gap, no white panel */ | |
| 97 | -#ownersTable table { | |
| 98 | - margin-bottom: 0; | |
| 34 | +.owners-empty-actions { | |
| 35 | + margin-top: 15px; | |
| 99 | 36 | } |
| 100 | 37 | |
| 38 | +/* Paginator reads as the table's footer: no gap, no white panel */ | |
| 101 | 39 | .owners-footer { |
| 102 | 40 | display: flex; |
| 103 | 41 | align-items: center; |
Fix: deleted; the two #ownersTable table rules merged into one.
Reviewer: the form kept form-horizontal only for its control-label styling, which the search-row rules then reset property by property.
| 2 | 2 | <div class="container xd-container"> |
| 3 | 3 | <h2>Owners</h2> |
| 4 | 4 | |
| 5 | - <form class="form-horizontal" id="search-owner-form" (ngSubmit)="search()"> | |
| 5 | + <form id="search-owner-form" (ngSubmit)="search()"> | |
| 6 | 6 | <div class="owner-search-row"> |
| 7 | - <label class="control-label" for="lastName">Last name</label> | |
| 7 | + <label for="lastName">Last name</label> | |
| 8 | 8 | <input class="form-control" size="30" |
| 9 | 9 | maxlength="80" id="lastName" name="lastName" [(ngModel)]="lastName"/> |
| 10 | 10 | <button type="submit" class="btn btn-default">Find Owner</button> |
owner-list.component.css: diff shown under Dead stylesheet rules from an earlier pager layout.
Fix: dropped the class; only margin and nowrap remain.
Reviewer: ::ng-deep rules hid .mat-sort-header-arrow's children and cancelled its animation styles; a Material upgrade breaks them silently.
| export class OwnerListComponent implements OnInit, OnDestroy { | ||
| 68 | 69 | this.navigate({size: event.pageSize, page: sizeChanged ? 1 : event.pageIndex + 1}); |
| 69 | 70 | } |
| 70 | 71 | |
| 71 | - onSort(sort: Sort) { | |
| 72 | - this.navigate({sort: `${sort.active},${sort.direction || 'asc'}`, page: 1}); | |
| 72 | + // A click on the sorted column flips its direction; on the other one it sorts ascending. | |
| 73 | + sortBy(column: SortColumn) { | |
| 74 | + const direction = this.sortColumn === column && this.sortDirection === 'asc' ? 'desc' : 'asc'; | |
| 75 | + this.navigate({sort: `${column},${direction}`, page: 1}); | |
| 76 | + } | |
| 77 | + | |
| 78 | + ariaSort(column: SortColumn): 'ascending' | 'descending' | null { | |
| 79 | + if (this.sortColumn !== column) { | |
| 80 | + return null; | |
| 81 | + } | |
| 82 | + return this.sortDirection === 'asc' ? 'ascending' : 'descending'; | |
| 83 | + } | |
| 84 | + | |
| 85 | + indicator(column: SortColumn) { | |
| 86 | + const active = this.sortColumn === column; | |
| 87 | + return {active, asc: active && this.sortDirection === 'asc', desc: active && this.sortDirection === 'desc'}; | |
| 73 | 88 | } |
| 74 | 89 | |
| 75 | - get sortColumn(): string { | |
| 90 | + private get sortColumn(): string { | |
| 76 | 91 | return this.query.sort.split(',')[0]; |
| 77 | 92 | } |
| 78 | 93 | |
| 79 | - get sortDirection(): 'asc' | 'desc' { | |
| 80 | - return this.query.sort.endsWith('desc') ? 'desc' : 'asc'; | |
| 94 | + private get sortDirection(): string { | |
| 95 | + return this.query.sort.split(',')[1]; | |
| 81 | 96 | } |
| 82 | 97 | |
| 83 | 98 | private show(page: OwnerPage) { |
| display:none; | ||
| 109 | 47 | background: transparent; |
| 110 | 48 | } |
| 111 | 49 | |
| 112 | -/* One row as wide as the table: label, stretching input, search button */ | |
| 113 | -.owner-search-row { | |
| 114 | - display: flex; | |
| 115 | - align-items: center; | |
| 116 | - gap: 15px; | |
| 117 | - margin-bottom: 15px; | |
| 118 | -} | |
| 119 | - | |
| 120 | -.owner-search-row .control-label { | |
| 121 | - padding-top: 0; | |
| 122 | - margin-bottom: 0; | |
| 123 | - text-align: left; | |
| 124 | - white-space: nowrap; | |
| 125 | -} | |
| 126 | - | |
| 127 | -.owner-search-row button { | |
| 128 | - flex-shrink: 0; | |
| 129 | -} | |
| 130 | - | |
| 131 | 50 | /* Sort indicator: a dim ▲▼ pair marks a sortable column, a bright ▲ or ▼ the active sort. */ |
| 132 | -/* Replaces Material's stem+halves arrow; !important beats its animations' inline styles. */ | |
| 133 | -::ng-deep #ownersTable .mat-sort-header-arrow { | |
| 51 | +/* A button, so the sort is reachable from the keyboard; styled to read as the header text */ | |
| 52 | +.sort-button { | |
| 53 | + padding: 0; | |
| 54 | + border: 0; | |
| 55 | + background: none; | |
| 56 | + color: inherit; | |
| 57 | + font: inherit; | |
| 58 | + cursor: pointer; | |
| 59 | +} | |
| 60 | + | |
| 61 | +.sort-indicator { | |
| 62 | + display: inline-flex; | |
| 134 | 63 | flex-direction: column; |
| 135 | - justify-content: center; | |
| 136 | - align-items: center; | |
| 137 | 64 | gap: 2px; |
| 138 | - width: 10px; | |
| 139 | - min-width: 10px; | |
| 140 | - color: #fff; | |
| 141 | - opacity: 0.4 !important; | |
| 142 | - transform: none !important; | |
| 65 | + margin-left: 6px; | |
| 66 | + vertical-align: middle; | |
| 67 | + opacity: 0.4; | |
| 143 | 68 | } |
| 144 | 69 | |
| 145 | -::ng-deep #ownersTable .mat-sort-header-arrow > * { | |
| 146 | - display: none; | |
| 70 | +.sort-indicator.active { | |
| 71 | + opacity: 1; | |
| 147 | 72 | } |
| 148 | 73 | |
| 149 | -::ng-deep #ownersTable .mat-sort-header-arrow::before, | |
| 150 | -::ng-deep #ownersTable .mat-sort-header-arrow::after { | |
| 74 | +.sort-indicator::before, | |
| 75 | +.sort-indicator::after { | |
| 151 | 76 | content: ''; |
| 152 | 77 | border-left: 5px solid transparent; |
| 153 | 78 | border-right: 5px solid transparent; |
| 154 | 79 | } |
| 155 | 80 | |
| 156 | -::ng-deep #ownersTable .mat-sort-header-arrow::before { | |
| 81 | +.sort-indicator::before { | |
| 157 | 82 | border-bottom: 5px solid currentColor; |
| 158 | 83 | } |
| 159 | 84 | |
| 160 | -::ng-deep #ownersTable .mat-sort-header-arrow::after { | |
| 85 | +.sort-indicator::after { | |
| 161 | 86 | border-top: 5px solid currentColor; |
| 162 | 87 | } |
| 163 | 88 | |
| 164 | -::ng-deep #ownersTable th[aria-sort="ascending"] .mat-sort-header-arrow, | |
| 165 | -::ng-deep #ownersTable th[aria-sort="descending"] .mat-sort-header-arrow { | |
| 166 | - opacity: 1 !important; | |
| 167 | -} | |
| 168 | - | |
| 169 | -::ng-deep #ownersTable th[aria-sort="ascending"] .mat-sort-header-arrow::after, | |
| 170 | -::ng-deep #ownersTable th[aria-sort="descending"] .mat-sort-header-arrow::before { | |
| 89 | +.sort-indicator.asc::after, | |
| 90 | +.sort-indicator.desc::before { | |
| 171 | 91 | display: none; |
| 172 | 92 | } |
owner-list.component.html: diff shown under Add Owner vanished when a search matched nobody.
Fix: the headers draw ▲/▼ from the component's own sort state; MatSort is gone, along with the dead || 'asc'.
Reviewer: th:nth-child(1..5) widths go wrong silently when a column is added or moved.
owner-list.component.css: diff shown under Dead stylesheet rules from an earlier pager layout.
owner-list.component.html: diff shown under Add Owner vanished when a search matched nobody.
Fix: one class per column.
Reviewer: .main-wrapper padding was set to 0 to cancel the gutter of the container-fluid class on the same div.
| 1 | -<div class="container-fluid main-wrapper"> | |
| 1 | +<div class="main-wrapper"> | |
| 2 | 2 | <nav class="navbar navbar-default " role="navigation"> |
| 3 | 3 | <div class="container-fluid"> |
| 4 | 4 | <div class="navbar-header"> |
| body { | ||
| 8 | 8 | |
| 9 | 9 | .main-wrapper { |
| 10 | 10 | flex: 1; |
| 11 | - /* the navbar runs edge to edge, not inside the container's gutter */ | |
| 12 | - padding-left: 0; | |
| 13 | - padding-right: 0; | |
| 14 | 11 | } |
| 15 | 12 | |
| 16 | 13 | div.navbar-header { |
Fix: removed the class instead.
Reviewer: OwnerQuery and OwnerListQuery restated listOwners' parameters, so a backend parameter change would not break the frontend build.
| 1 | 1 | import { Injectable } from '@angular/core'; |
| 2 | 2 | import { Owner } from './owner'; |
| 3 | 3 | import { OwnerPage } from './owner-page'; |
| 4 | +import { operations } from '../generated/api-types'; | |
| 4 | 5 | import { Observable } from 'rxjs'; |
| 5 | 6 | import { environment } from '../../environments/environment'; |
| 6 | 7 | import { HttpClient, HttpParams } from '@angular/common/http'; |
| 7 | 8 | import { catchError } from 'rxjs/operators'; |
| 8 | 9 | import { HandleError, HttpErrorHandler } from '../error.service'; |
| 9 | 10 | |
| 10 | -export interface OwnerQuery { | |
| 11 | - lastName?: string; | |
| 12 | - page?: number; | |
| 13 | - size?: number; | |
| 14 | - sort?: string; | |
| 15 | -} | |
| 11 | +export type OwnerQuery = NonNullable<operations['listOwners']['parameters']['query']>; | |
| 16 | 12 | |
| 17 | 13 | @Injectable() |
| 18 | 14 | export class OwnerService { |
| 1 | 1 | import {Component, OnDestroy, OnInit} from '@angular/core'; |
| 2 | 2 | import {ActivatedRoute, ParamMap, Params, Router} from '@angular/router'; |
| 3 | 3 | import {PageEvent} from '@angular/material/paginator'; |
| 4 | -import {Sort} from '@angular/material/sort'; | |
| 5 | -import {EMPTY, Subscription} from 'rxjs'; | |
| 4 | +import {EMPTY, merge, Subject, Subscription} from 'rxjs'; | |
| 6 | 5 | import {catchError, map, switchMap, tap} from 'rxjs/operators'; |
| 7 | -import {OwnerService} from '../owner.service'; | |
| 6 | +import {OwnerQuery, OwnerService} from '../owner.service'; | |
| 8 | 7 | import {OwnerPage} from '../owner-page'; |
| 9 | 8 | |
| 10 | -/** What the grid shows, as kept in the URL: `page` is 1-based there, and `sort` is spelled as the API spells it. */ | |
| 11 | -interface OwnerListQuery { | |
| 12 | - lastName: string; | |
| 13 | - page: number; | |
| 14 | - size: number; | |
| 15 | - sort: string; | |
| 16 | -} | |
| 9 | +/** What the grid shows, as kept in the URL: the API's query, except that `page` is 1-based there. */ | |
| 10 | +type OwnerListQuery = Required<OwnerQuery>; | |
| 11 | +type SortColumn = 'name' | 'city'; | |
| 17 | 12 | |
| 18 | 13 | const DEFAULTS: OwnerListQuery = {lastName: '', page: 1, size: 10, sort: 'name,asc'}; |
| 19 | 14 | const PAGE_SIZES = [5, 10, 20]; |
owner-list.component.ts: diff shown under Find Owner with an unchanged name did not search again.
Fix: OwnerQuery comes from api-types.ts; the component uses Required<OwnerQuery>.
Reviewer: handlerError always rethrows, so {} as OwnerPage suggested failures became an empty page.
| export class OwnerService { | ||
| 37 | 33 | } |
| 38 | 34 | return this.http |
| 39 | 35 | .get<OwnerPage>(this.entityUrl, {params}) |
| 40 | - .pipe(catchError(this.handlerError('getOwnersPage', {} as OwnerPage))); | |
| 36 | + .pipe(catchError(this.handlerError<OwnerPage>('getOwnersPage'))); | |
| 41 | 37 | } |
| 42 | 38 | |
| 43 | 39 | getOwnerById(ownerId: number): Observable<Owner> { |
Fix: only the type argument is kept.
Reviewer: @BatchSize fixed the N+1 only on the two collections the grid walks; Vet.specialties kept its own.
| spring.jpa.show-sql=true | ||
| 11 | 11 | # call a `select ... from owners` came from; the sequence diagrams label their DB arrows |
| 12 | 12 | # with it and keep the SQL itself behind a click. |
| 13 | 13 | spring.jpa.properties.hibernate.use_sql_comments=true |
| 14 | +# Lazy collections load in batches (one query per page of owners, not one per row) | |
| 15 | +spring.jpa.properties.hibernate.default_batch_fetch_size=100 | |
| 14 | 16 | |
| 15 | 17 | # Flyway |
| 16 | 18 | spring.flyway.enabled=true |
| public class Owner { | ||
| 55 | 53 | private String telephone; |
| 56 | 54 | |
| 57 | 55 | @OneToMany(cascade = CascadeType.ALL, mappedBy = "owner", fetch = FetchType.LAZY) |
| 58 | - @BatchSize(size = 100) // a page of owners loads its pets (and their visits) in one query, not one per row | |
| 59 | 56 | private Set<Pet> pets = new HashSet<>(); |
| 60 | 57 | |
| 61 | 58 | public List<Pet> getPets() { |
| public class Pet { | ||
| 50 | 48 | private Owner owner; |
| 51 | 49 | |
| 52 | 50 | @OneToMany(cascade = CascadeType.ALL, mappedBy = "pet", fetch = FetchType.LAZY) |
| 53 | - @BatchSize(size = 100) | |
| 54 | 51 | private Set<Visit> visits = new HashSet<>(); |
| 55 | 52 | |
| 56 | 53 | public List<Visit> getVisitsSortedByDate() { |
Fix: hibernate.default_batch_fetch_size=100 replaces both annotations.
Reviewer: every other entity↔DTO conversion lives in a mapper; this one was five accessor calls in listOwners.
| public class OwnerMapper { | ||
| 27 | 29 | .setPets(petMapper.toPetsDto(owner.getPets())); |
| 28 | 30 | } |
| 29 | 31 | |
| 32 | + public OwnerPageDto toOwnerPageDto(Page<Owner> owners) { | |
| 33 | + return new OwnerPageDto(toOwnerDtoCollection(owners.getContent()), | |
| 34 | + owners.getTotalElements(), owners.getTotalPages(), owners.getNumber(), owners.getSize()); | |
| 35 | + } | |
| 36 | + | |
| 30 | 37 | public Owner toOwner(OwnerFieldsDto ownerDto) { |
| 31 | 38 | Owner owner = new Owner(); |
| 32 | 39 | owner.setFirstName(ownerDto.getFirstName()); |
OwnerRestController.java: diff shown under Bad page input answered 500 instead of 400.
Fix: OwnerMapper.toOwnerPageDto.
Reviewer: the spec carried its own BehaviorSubject stub beside the shared one 15 specs use.
| export class ActivatedRouteStub { | ||
| 49 | 49 | this.subject.next(params); |
| 50 | 50 | } |
| 51 | 51 | |
| 52 | + // ActivatedRoute.queryParamMap is Observable too | |
| 53 | + private querySubject = new BehaviorSubject(convertToParamMap({})); | |
| 54 | + queryParamMap = this.querySubject.asObservable(); | |
| 55 | + | |
| 56 | + setQueryParams(params: Params) { | |
| 57 | + this.querySubject.next(convertToParamMap(params)); | |
| 58 | + } | |
| 59 | + | |
| 52 | 60 | // ActivatedRoute.snapshot.params |
| 53 | 61 | get snapshot() { |
| 54 | 62 | this.testParams = {id: 1}; |
Fix: the shared stub gained queryParamMap and setQueryParams.
+36const aPage = (content: Owner[], totalElements = content.length,
+37 totalPages = Math.ceil(totalElements / 10)): OwnerPage =>
+38 ({content, totalElements, totalPages, number: 0, size: 10});
Reviewer: the loop paged through owners because leftover pet-less "Ada Acceptance" owners sort first, one more round trip per hundred leftovers.
| 1 | 1 | import {expect, Page} from '@playwright/test'; |
| 2 | -import axios from 'axios'; | |
| 2 | +import {ApiClient} from './support/api-client'; | |
| 3 | 3 | |
| 4 | 4 | // The sentences of add-visit.spec.ts, as plain functions: named for what the |
| 5 | 5 | // reader of a scenario wants to see, not for the widget being clicked. The |
| 6 | 6 | // selectors live here so the spec never mentions one. |
| 7 | 7 | |
| 8 | -const API_BASE = process.env.API_BASE_URL || 'http://localhost:8080/api'; | |
| 9 | - | |
| 10 | 8 | export interface OwnerWithPet { |
| 11 | 9 | ownerId: number; |
| 12 | 10 | petId: number; |
| 13 | 11 | } |
| 14 | 12 | |
| 15 | -// Walks the pages: the pet-less owners other runs leave behind ("Ada Acceptance…") sort first by name. | |
| 13 | +// The seed's owner 1 (Kevin McCallister) has a pet, so the list needs no searching. | |
| 14 | +const SEEDED_OWNER_WITH_PET = 1; | |
| 15 | + | |
| 16 | 16 | export async function an_owner_with_at_least_one_pet_exists(): Promise<OwnerWithPet> { |
| 17 | - for (let page = 0; ; page++) { | |
| 18 | - const {data} = await axios.get(`${API_BASE}/owners?size=100&page=${page}`, {timeout: 10_000}); | |
| 19 | - const ownerWithPet = data.content.find((o: any) => Array.isArray(o.pets) && o.pets.length > 0); | |
| 20 | - if (ownerWithPet) { | |
| 21 | - return {ownerId: ownerWithPet.id, petId: ownerWithPet.pets[0].id}; | |
| 22 | - } | |
| 23 | - if (page + 1 >= data.totalPages) { | |
| 24 | - throw new Error('No owner with a pet found in the system; cannot run add-visit scenario'); | |
| 25 | - } | |
| 17 | + const owner = await new ApiClient().fetchOwner(SEEDED_OWNER_WITH_PET); | |
| 18 | + if (owner.pets.length === 0) { | |
| 19 | + throw new Error(`Owner ${SEEDED_OWNER_WITH_PET} has no pet — did db/seed/R__seed.sql change?`); | |
| 26 | 20 | } |
| 21 | + return {ownerId: owner.id, petId: owner.pets[0].id}; | |
| 27 | 22 | } |
| 28 | 23 | |
| 29 | 24 | export async function open_owner_detail_page(page: Page, ownerId: number): Promise<void> { |
| export class ApiClient { | ||
| 28 | 40 | return response.data; |
| 29 | 41 | } |
| 30 | 42 | |
| 43 | + async fetchOwnersPage(query: string): Promise<OwnerPage> { | |
| 44 | + const response = await this.client.get<OwnerPage>(`/owners?${query}`); | |
| 45 | + return response.data; | |
| 46 | + } | |
| 47 | + | |
| 48 | + async fetchOwner(ownerId: number): Promise<OwnerSummary> { | |
| 49 | + const response = await this.client.get<OwnerSummary>(`/owners/${ownerId}`); | |
| 50 | + return response.data; | |
| 51 | + } | |
| 52 | + | |
| 31 | 53 | static sortedByDate<T extends {date: string}>(rows: T[]): T[] { |
| 32 | 54 | return [...rows].sort((a, b) => a.date.localeCompare(b.date)); |
| 33 | 55 | } |
Fix: one GET of seed owner 1, through ApiClient.
Reviewer: two steps differed by one word, the cell locator and poll lived in three places, and the Background asked for the Potters twice, sequentially, on localhost.
| import {PlaywrightWorld} from './support/world'; | ||
| 11 | 11 | // Nothing below decides anything: the Background states the data, the Examples |
| 12 | 12 | // table states the search term and the expected result set. |
| 13 | 13 | |
| 14 | -const API_BASE = process.env.API_BASE_URL || 'http://localhost:8080/api'; | |
| 14 | +const api = new ApiClient(); | |
| 15 | 15 | |
| 16 | 16 | const fullName = (o: {firstName: string; lastName: string}) => `${o.firstName} ${o.lastName}`; |
| 17 | 17 | const namesIn = (cell: string) => cell.split(',').map((n) => n.trim()).filter(Boolean); |
| 18 | 18 | |
| 19 | +const listedNames = async (world: PlaywrightWorld) => | |
| 20 | + (await world.page.locator('#ownersTable td.ownerFullName').allTextContents()).map((t) => t.trim()).filter(Boolean); | |
| 21 | + | |
| 19 | 22 | /** Polls until the table has settled on exactly `expected` — order-insensitive. */ |
| 20 | 23 | async function expectOwnersListed(world: PlaywrightWorld, expected: string[]): Promise<void> { |
| 21 | - const cells = world.page.locator('#ownersTable td.ownerFullName'); | |
| 22 | - const listed = async () => (await cells.allTextContents()).map((t) => t.trim()).filter(Boolean).sort(); | |
| 23 | - | |
| 24 | - await expect.poll(listed, {timeout: 10_000}).toEqual([...expected].sort()); | |
| 24 | + await expect.poll(async () => (await listedNames(world)).sort(), {timeout: 10_000}).toEqual([...expected].sort()); | |
| 25 | 25 | } |
| 26 | 26 | |
| 27 | 27 | /** Polls until the table shows exactly `expected`, in that order. */ |
| 28 | 28 | async function expectOwnersListedInOrder(world: PlaywrightWorld, expected: string[]): Promise<void> { |
| 29 | - const cells = world.page.locator('#ownersTable td.ownerFullName'); | |
| 30 | - await expect.poll(async () => (await cells.allTextContents()).map((t) => t.trim()), {timeout: 10_000}) | |
| 31 | - .toEqual(expected); | |
| 29 | + await expect.poll(() => listedNames(world), {timeout: 10_000}).toEqual(expected); | |
| 32 | 30 | } |
| 33 | 31 | |
| 34 | 32 | /** The full names the API lists first for this query — what the grid must show, in that order. */ |
| 35 | 33 | async function firstPageFromApi(query: string): Promise<string[]> { |
| 36 | - const {data} = await axios.get(`${API_BASE}/owners?${query}`, {timeout: 10_000}); | |
| 37 | - return data.content.map(fullName); | |
| 34 | + return (await api.fetchOwnersPage(query)).content.map(fullName); | |
| 38 | 35 | } |
| 39 | 36 | |
| 40 | 37 | /** |
| async function firstPageFromApi(query: string): Promise<string[]> { | ||
| 43 | 40 | * instead of looking like a broken search. |
| 44 | 41 | */ |
| 45 | 42 | Given('the clinic has these owners', async function (this: PlaywrightWorld, owners: DataTable) { |
| 46 | - for (const [name] of owners.raw()) { | |
| 47 | - const lastName = name.trim().split(' ').pop(); | |
| 48 | - const {data} = await axios.get(`${API_BASE}/owners?lastName=${lastName}&size=100`, {timeout: 10_000}); | |
| 49 | - expect(data.content.map(fullName), 'is the backend up and the DB seeded by Flyway?').toContain(name.trim()); | |
| 50 | - } | |
| 51 | - const {data} = await axios.get(`${API_BASE}/owners?size=1`, {timeout: 10_000}); | |
| 52 | - this.ownerCount = data.totalElements; | |
| 43 | + const names = owners.raw().map(([name]) => name.trim()); | |
| 44 | + const lastNames = [...new Set(names.map((name) => name.split(' ').pop()))]; | |
| 45 | + const [all, ...matches] = await Promise.all([ | |
| 46 | + api.fetchOwnersPage('size=1'), | |
| 47 | + ...lastNames.map((lastName) => api.fetchOwnersPage(`lastName=${lastName}&size=100`)), | |
| 48 | + ]); | |
| 49 | + const found = matches.flatMap((page) => page.content.map(fullName)); | |
| 50 | + expect(found, 'is the backend up and the DB seeded by Flyway?').toEqual(expect.arrayContaining(names)); | |
| 51 | + this.ownerCount = all.totalElements; | |
| 53 | 52 | }); |
| 54 | 53 | |
| 55 | 54 | When('I open the owners page', async function (this: PlaywrightWorld) { |
| When('I choose {int} rows per page', async function (this: PlaywrightWorld, size | ||
| 68 | 67 | }); |
| 69 | 68 | |
| 70 | 69 | When('I sort the owners by {string}', async function (this: PlaywrightWorld, column: string) { |
| 71 | - await this.page.locator(`#ownersTable th:has-text("${column}")`).click(); | |
| 70 | + await this.page.locator(`#ownersTable th:has-text("${column}") button`).click(); | |
| 72 | 71 | }); |
| 73 | 72 | |
| 74 | 73 | Then('exactly these owners are listed: {string}', async function (this: PlaywrightWorld, owners: string) { |
| 75 | 74 | await expectOwnersListed(this, namesIn(owners)); |
| 76 | 75 | }); |
| 77 | 76 | |
| 78 | -Then('the first 10 owners by name are listed', async function (this: PlaywrightWorld) { | |
| 79 | - await expectOwnersListedInOrder(this, await firstPageFromApi('sort=name,asc&size=10')); | |
| 80 | -}); | |
| 81 | - | |
| 82 | -Then('the first 10 owners by city are listed', async function (this: PlaywrightWorld) { | |
| 83 | - await expectOwnersListedInOrder(this, await firstPageFromApi('sort=city,asc&size=10')); | |
| 77 | +Then('the first 10 owners by {word} are listed', async function (this: PlaywrightWorld, sortKey: string) { | |
| 78 | + await expectOwnersListedInOrder(this, await firstPageFromApi(`sort=${sortKey},asc&size=10`)); | |
| 84 | 79 | }); |
| 85 | 80 | |
| 86 | 81 | Then('{int} owners are listed', async function (this: PlaywrightWorld, count: number) { |
api-client.ts: diff shown under Add-visit glue walked owner pages to find a pet.
Fix: one {word} step, a listedNames helper, deduped lookups in Promise.all through ApiClient.
Hook: scripts/check-line-length.py blocked the push on a 122-character axios.post line.
visit-date-range.feature.glue.ts: diff shown under Visit-date scenario still read the owner list as an array.
Fix: the request body is wrapped one field group per line.
Hook: the badRequest extraction removed @ResponseStatus(BAD_REQUEST); springdoc then dropped the 400 response from every operation in openapi.yaml.
ExceptionControllerAdvice.java: diff shown under Bad page input answered 500 instead of 400.
Fix: restored on the two validation handlers; openapi.yaml back to its committed state.
Reviewer: zonky inherits the runner's locale; under C.UTF-8 'Łukasz' sorts after 'Mister' and accentedNames_sortAlphabetically failed CI.
| 1 | +# Loaded beside the main application.properties (classpath:/config/ is read in addition, not instead). | |
| 2 | +# The embedded test database sorts like a person would: ICU English, whatever locale the machine | |
| 3 | +# runs under - CI's C.UTF-8 put 'Lukasz' with a stroke after 'Mister'. | |
| 4 | +zonky.test.database.postgres.initdb.properties.locale-provider=icu | |
| 5 | +zonky.test.database.postgres.initdb.properties.icu-locale=en |
| 32 | 32 | <type>pom</type> |
| 33 | 33 | <scope>import</scope> |
| 34 | 34 | </dependency> |
| 35 | + <!-- Postgres 16 for the embedded test database on every platform (embedded-postgres | |
| 36 | + defaults to 14, which cannot make ICU the database's default collation) --> | |
| 37 | + <dependency> | |
| 38 | + <groupId>io.zonky.test.postgres</groupId> | |
| 39 | + <artifactId>embedded-postgres-binaries-bom</artifactId> | |
| 40 | + <version>16.2.0</version> | |
| 41 | + <type>pom</type> | |
| 42 | + <scope>import</scope> | |
| 43 | + </dependency> | |
| 35 | 44 | </dependencies> |
| 36 | 45 | </dependencyManagement> |
| 37 | 46 |
Fix: Postgres 16 binaries on every platform and an ICU English default collation for the test database.
CI: (click) on a <th> has no keyboard equivalent, so keyboard users could not sort; it failed the quality gate.
owner-list.component.html: diff shown under Add Owner vanished when a search matched nobody.
owner-search.feature.glue.ts: diff shown under Owner-search glue repeated steps, locators and calls.
Fix: each sortable header holds a plain-styled <button>, focusable and Enter/Space-activated natively.
CI: router.navigate returns a promise nobody handled; a failed navigation was swallowed silently.
| export class OwnerListComponent implements OnInit, OnDestroy { | ||
| 92 | 107 | |
| 93 | 108 | private navigate(change: Partial<OwnerListQuery>, replaceUrl = false) { |
| 94 | 109 | const queryParams = toUrl({...this.query, ...change}); |
| 95 | - this.router.navigate([], {relativeTo: this.route, queryParams, replaceUrl}); | |
| 110 | + this.router.navigate([], {relativeTo: this.route, queryParams, replaceUrl}) | |
| 111 | + .catch((error) => this.errorMessage = String(error)); | |
| 96 | 112 | } |
| 97 | 113 | } |
| 98 | 114 |
Fix: a rejection now shows as the grid's error message.
CI: the router, route, service and reload subject are never reassigned.
owner-list.component.ts: diff shown under Find Owner with an unchanged name did not search again.
Fix: marked readonly.
| end and confirmed as written before implementation. | ||
| 141 | 141 | |
| 142 | 142 | - **[The sort collation differs between dev, CI and prod]** The accented-name scenario passes |
| 143 | 143 | only under a linguistic collation, and zonky's `initdb` follows the machine locale. |
| 144 | - → The scenario runs in CI. If CI collates `C`, pin zonky's locale in the shared test | |
| 145 | - config rather than weakening the scenario. | |
| 144 | + → Done: CI collated `C`, so the test database is pinned to ICU English | |
| 145 | + (`petclinic-backend/src/test/resources/config/application.properties`), not the scenario weakened. | |
| 146 | 146 | - **[`count(*)` on every page request]** At 100k rows, a filtered or unfiltered count is in the low |
| 147 | 147 | milliseconds. → Accept it, because the total is what the paginator shows. Revisit at millions of rows. |
| 148 | 148 | - **[Deep offsets get slower]** `OFFSET 99990` still walks 99,990 index entries. → It stays acceptable at |
| import org.springframework.beans.support.MutableSortDefinition; | ||
| 11 | 11 | import org.springframework.beans.support.PropertyComparator; |
| 12 | 12 | import org.springframework.core.style.ToStringCreator; |
| 13 | 13 | |
| 14 | -import org.hibernate.annotations.BatchSize; | |
| 15 | - | |
| 16 | 14 | import jakarta.persistence.CascadeType; |
| 17 | 15 | import jakarta.persistence.Entity; |
| 18 | 16 | import jakarta.persistence.FetchType; |
| import java.util.Set; | ||
| 10 | 10 | import org.springframework.beans.support.MutableSortDefinition; |
| 11 | 11 | import org.springframework.beans.support.PropertyComparator; |
| 12 | 12 | |
| 13 | -import org.hibernate.annotations.BatchSize; | |
| 14 | - | |
| 15 | 13 | import jakarta.persistence.CascadeType; |
| 16 | 14 | import jakarta.persistence.Column; |
| 17 | 15 | import jakarta.persistence.Entity; |
| 1 | 1 | package victor.training.petclinic.mapper; |
| 2 | 2 | |
| 3 | +import org.springframework.data.domain.Page; | |
| 3 | 4 | import org.springframework.stereotype.Component; |
| 4 | 5 | import victor.training.petclinic.domain.Owner; |
| 5 | 6 | import victor.training.petclinic.rest.dto.OwnerDto; |
| 6 | 7 | import victor.training.petclinic.rest.dto.OwnerFieldsDto; |
| 8 | +import victor.training.petclinic.rest.dto.OwnerPageDto; | |
| 7 | 9 | |
| 8 | 10 | import java.util.ArrayList; |
| 9 | 11 | import java.util.List; |
| import jakarta.validation.ValidationException; | ||
| 8 | 8 | import org.springframework.http.HttpStatus; |
| 9 | 9 | import org.springframework.http.ProblemDetail; |
| 10 | 10 | import org.springframework.http.ResponseEntity; |
| 11 | -import org.springframework.validation.BindingResult; | |
| 12 | 11 | import org.springframework.web.bind.MethodArgumentNotValidException; |
| 13 | 12 | import org.springframework.web.bind.annotation.ExceptionHandler; |
| 14 | 13 | import org.springframework.web.bind.annotation.ResponseStatus; |
| 15 | 14 | import org.springframework.web.bind.annotation.RestControllerAdvice; |
| 15 | +import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException; | |
| 16 | 16 | |
| 17 | 17 | import java.time.Instant; |
| 18 | 18 | import java.util.List; |
| import static org.springframework.http.HttpStatus.NOT_FOUND; | ||
| 34 | 34 | @RestControllerAdvice(basePackages = "victor.training.petclinic.rest") |
| 35 | 35 | public class ExceptionControllerAdvice { |
| 36 | 36 | private static final Logger log = LoggerFactory.getLogger(ExceptionControllerAdvice.class); |
| 37 | + private static final String SEE_ERRORS = "Validation failed for request. See 'errors' for details."; | |
| 37 | 38 | |
| 38 | 39 | private ProblemDetail buildProblemDetail(String title, String detail, HttpStatus status, |
| 39 | 40 | HttpServletRequest request) { |
| 1 | 1 | import {ComponentFixture, TestBed, waitForAsync} from '@angular/core/testing'; |
| 2 | 2 | import {By} from '@angular/platform-browser'; |
| 3 | 3 | import {NO_ERRORS_SCHEMA} from '@angular/core'; |
| 4 | -import {ActivatedRoute, convertToParamMap, Params, Router} from '@angular/router'; | |
| 4 | +import {ActivatedRoute, Router} from '@angular/router'; | |
| 5 | 5 | import {RouterTestingModule} from '@angular/router/testing'; |
| 6 | 6 | import {CommonModule} from '@angular/common'; |
| 7 | 7 | import {FormsModule} from '@angular/forms'; |
| 8 | 8 | import {NoopAnimationsModule} from '@angular/platform-browser/animations'; |
| 9 | -import {BehaviorSubject, Observable, of, Subject, throwError} from 'rxjs'; | |
| 9 | +import {Observable, of, Subject, throwError} from 'rxjs'; | |
| 10 | 10 | |
| 11 | 11 | import {OwnerListComponent} from './owner-list.component'; |
| 12 | 12 | import {OwnerQuery, OwnerService} from '../owner.service'; |
| 13 | 13 | import {Owner} from '../owner'; |
| 14 | 14 | import {OwnerPage} from '../owner-page'; |
| 15 | 15 | import {OwnersModule} from '../owners.module'; |
| 16 | +import {ActivatedRouteStub} from '../../testing/router-stubs'; | |
| 16 | 17 | import Spy = jasmine.Spy; |
| 17 | 18 | |
| 18 | 19 | class OwnerServiceStub { |
| class OwnerServiceStub { | ||
| 21 | 22 | } |
| 22 | 23 | } |
| 23 | 24 | |
| 24 | -/** The URL's query string, as the component reads it. */ | |
| 25 | -class QueryParamsStub { | |
| 26 | - private subject = new BehaviorSubject(convertToParamMap({})); | |
| 27 | - queryParamMap = this.subject.asObservable(); | |
| 28 | - | |
| 29 | - set(params: Params) { | |
| 30 | - this.subject.next(convertToParamMap(params)); | |
| 31 | - } | |
| 32 | -} | |
| 33 | - | |
| 34 | 25 | describe('OwnerListComponent', () => { |
| 35 | 26 | let component: OwnerListComponent; |
| 36 | 27 | let fixture: ComponentFixture<OwnerListComponent>; |
| 37 | 28 | let getOwnersPage: Spy; |
| 38 | 29 | let navigate: Spy; |
| 39 | - let url: QueryParamsStub; | |
| 30 | + let route: ActivatedRouteStub; | |
| 40 | 31 | |
| 41 | 32 | const george: Owner = { |
| 42 | 33 | id: 1, firstName: 'George', lastName: 'Franklin', address: '110 W. Liberty St.', |
| describe('OwnerListComponent', () => { | ||
| 52 | 43 | imports: [CommonModule, FormsModule, NoopAnimationsModule, OwnersModule, RouterTestingModule], |
| 53 | 44 | providers: [ |
| 54 | 45 | {provide: OwnerService, useClass: OwnerServiceStub}, |
| 55 | - {provide: ActivatedRoute, useClass: QueryParamsStub} | |
| 46 | + {provide: ActivatedRoute, useClass: ActivatedRouteStub} | |
| 56 | 47 | ] |
| 57 | 48 | }).compileComponents(); |
| 58 | 49 | })); |
| describe('OwnerListComponent', () => { | ||
| 60 | 51 | beforeEach(() => { |
| 61 | 52 | fixture = TestBed.createComponent(OwnerListComponent); |
| 62 | 53 | component = fixture.componentInstance; |
| 63 | - url = TestBed.inject(ActivatedRoute) as unknown as QueryParamsStub; | |
| 54 | + route = TestBed.inject(ActivatedRoute) as unknown as ActivatedRouteStub; | |
| 64 | 55 | getOwnersPage = spyOn(TestBed.inject(OwnerService), 'getOwnersPage').and.returnValue(of(aPage([george]))); |
| 65 | 56 | navigate = spyOn(TestBed.inject(Router), 'navigate').and.returnValue(Promise.resolve(true)); |
| 66 | 57 | }); |
| describe('OwnerListComponent', () => { | ||
| 75 | 66 | }); |
| 76 | 67 | |
| 77 | 68 | it('asks for what the URL says, with its 1-based page turned 0-based', () => { |
| 78 | - url.set({lastName: 'Pot', page: '3', size: '5', sort: 'city,desc'}); | |
| 69 | + route.setQueryParams({lastName: 'Pot', page: '3', size: '5', sort: 'city,desc'}); | |
| 79 | 70 | fixture.detectChanges(); |
| 80 | 71 | |
| 81 | 72 | expect(getOwnersPage).toHaveBeenCalledWith({lastName: 'Pot', page: 2, size: 5, sort: 'city,desc'}); |
| describe('OwnerListComponent', () => { | ||
| 83 | 74 | }); |
| 84 | 75 | |
| 85 | 76 | it('falls back to the defaults for values the URL gets wrong', () => { |
| 86 | - url.set({page: '-2', size: '7', sort: 'telephone,asc'}); | |
| 77 | + route.setQueryParams({page: '-2', size: '7', sort: 'telephone,asc'}); | |
| 87 | 78 | fixture.detectChanges(); |
| 88 | 79 | |
| 89 | 80 | expect(getOwnersPage).toHaveBeenCalledWith({lastName: '', page: 0, size: 10, sort: 'name,asc'}); |
| describe('OwnerListComponent', () => { | ||
| 96 | 87 | }); |
| 97 | 88 | |
| 98 | 89 | it('a search goes back to the first page, keeping the sort and size', () => { |
| 99 | - url.set({page: '3', size: '5', sort: 'city,asc'}); | |
| 90 | + route.setQueryParams({page: '3', size: '5', sort: 'city,asc'}); | |
| 100 | 91 | fixture.detectChanges(); |
| 101 | 92 | |
| 102 | 93 | component.lastName = 'Pot'; |
| describe('OwnerListComponent', () => { | ||
| 106 | 97 | }); |
| 107 | 98 | |
| 108 | 99 | it('moving to another page keeps the rest of the query', () => { |
| 109 | - url.set({lastName: 'Pot'}); | |
| 100 | + route.setQueryParams({lastName: 'Pot'}); | |
| 110 | 101 | fixture.detectChanges(); |
| 111 | 102 | |
| 112 | 103 | component.onPage({pageIndex: 2, pageSize: 10, length: 26}); |
| describe('OwnerListComponent', () => { | ||
| 115 | 106 | }); |
| 116 | 107 | |
| 117 | 108 | it('a new page size goes back to the first page', () => { |
| 118 | - url.set({page: '3'}); | |
| 109 | + route.setQueryParams({page: '3'}); | |
| 119 | 110 | fixture.detectChanges(); |
| 120 | 111 | |
| 121 | 112 | component.onPage({pageIndex: 1, pageSize: 5, length: 26}); |
| describe('OwnerListComponent', () => { | ||
| 168 | 190 | |
| 169 | 191 | it('moves to the last page when the URL points past it', () => { |
| 170 | 192 | getOwnersPage.and.returnValue(of(aPage([], 26, 3))); |
| 171 | - url.set({page: '9'}); | |
| 193 | + route.setQueryParams({page: '9'}); | |
| 172 | 194 | fixture.detectChanges(); |
| 173 | 195 | |
| 174 | 196 | expect(navigatedTo()).toEqual({page: 3}); |
| describe('OwnerListComponent', () => { | ||
| 182 | 204 | fixture.detectChanges(); |
| 183 | 205 | |
| 184 | 206 | getOwnersPage.and.returnValue(of(aPage([george]))); |
| 185 | - url.set({lastName: 'Franklin'}); | |
| 207 | + route.setQueryParams({lastName: 'Franklin'}); | |
| 186 | 208 | slowAnswer.next(aPage([{...george, id: 2, lastName: 'Davis'}])); |
| 187 | 209 | |
| 188 | 210 | expect(component.page?.content).toEqual([george]); |
| import {OwnerEditComponent} from './owner-edit/owner-edit.component'; | ||
| 9 | 9 | import {OwnersRoutingModule} from './owners-routing.module'; |
| 10 | 10 | import {PetsModule} from '../pets/pets.module'; |
| 11 | 11 | import {MatPaginatorModule} from '@angular/material/paginator'; |
| 12 | -import {MatSortModule} from '@angular/material/sort'; | |
| 13 | 12 | |
| 14 | 13 | @NgModule({ |
| 15 | 14 | imports: [ |
| import {MatSortModule} from '@angular/material/sort'; | ||
| 17 | 16 | FormsModule, |
| 18 | 17 | OwnersRoutingModule, |
| 19 | 18 | PetsModule, |
| 20 | - MatPaginatorModule, | |
| 21 | - MatSortModule | |
| 19 | + MatPaginatorModule | |
| 22 | 20 | ], |
| 23 | 21 | declarations: [ |
| 24 | 22 | OwnerListComponent, |
| 2 | 2 | export {ActivatedRoute, Router, RouterLink, RouterOutlet} from '@angular/router'; |
| 3 | 3 | |
| 4 | 4 | import {Component, Directive, HostListener, Injectable, Input} from '@angular/core'; |
| 5 | -import {NavigationExtras} from '@angular/router'; | |
| 5 | +import {convertToParamMap, NavigationExtras, Params} from '@angular/router'; | |
| 6 | 6 | // Only implements params and part of snapshot.params |
| 7 | 7 | import {BehaviorSubject} from 'rxjs'; |
| 8 | 8 |
| 1 | 1 | import {DataTable, Given, When, Then} from '@cucumber/cucumber'; |
| 2 | 2 | import {expect} from '@playwright/test'; |
| 3 | -import axios from 'axios'; | |
| 3 | +import {ApiClient} from './support/api-client'; | |
| 4 | 4 | import {PlaywrightWorld} from './support/world'; |
| 5 | 5 | |
| 6 | 6 | // Gherkin, bound directly: the steps do the work themselves, with no DSL layer |
| export interface VisitDto { | ||
| 11 | 11 | ownerLastName?: string; |
| 12 | 12 | } |
| 13 | 13 | |
| 14 | +export interface OwnerSummary { | |
| 15 | + id: number; | |
| 16 | + firstName: string; | |
| 17 | + lastName: string; | |
| 18 | + pets: {id: number}[]; | |
| 19 | +} | |
| 20 | + | |
| 21 | +export interface OwnerPage { | |
| 22 | + content: OwnerSummary[]; | |
| 23 | + totalElements: number; | |
| 24 | +} | |
| 25 | + | |
| 14 | 26 | export class ApiClient { |
| 15 | 27 | private client: AxiosInstance; |
| 16 | 28 |
| class ListGetFirstTest implements RewriteTest { | ||
| 46 | 46 | import java.util.List; |
| 47 | 47 | class Owner { List<String> getPets() { return List.of(); } } |
| 48 | 48 | class A { |
| 49 | - String firstPet(Owner owner) { | |
| 50 | 49 | return owner.getPets().get(0); |
| 51 | 50 | } |
| 52 | 51 | } |
8 generated files re-recorded in the fix commits (0b99e29c..d01c3776) — regenerated output, not a fix, so not drawn.
Demo
API
Data
Also changed, not drawn: indexes added on owners (city, first_name, last_name, id), owners (last_name text_pattern_ops), owners (first_name, last_name, id).
Tests
Requirements of the OpenSpec change paginate-owners-grid
GET /api/owners SHALL return a single page of owners as {content, totalElements, totalPages, number, size}, where number is zero-based. Without paging parameters it SHALL return page 0 of size 10, sorted by name ascending. Each owner in content keeps its current shape, pets included.size from 1 to 100 and SHALL reject any other value, and any negative page, with 400 and a message naming the parameter. It SHALL NOT silently adjust the value.sort as name or city, optionally followed by ,asc or ,desc (default asc). name orders by first name, then last name; city orders by city, then first name, then last name. Any other value SHALL be rejected with 400.lastName parameter SHALL keep its current meaning, a case-sensitive prefix of the last name, and totalElements SHALL count only the owners that match it.+42 −12 ✍️5
Sequence
The trace of OwnerListTest: unfilteredTotal_countsEveryOwner(), in Grafana Tempo. Compare it with that test's diagram below. Recorded while the e2e tests ran, 100% sampled: every request is in it.

Structure
Op counts compare only the 2 tests traced on both sides.
Unit-tested against the traced sequence diagrams.
walked by a testno test walks itcalled in the traces, missing from the drawing
walked by a testno test walks itcalled in the traces, missing from the drawing
walked by a testno test walks itcalled in the traces, missing from the drawing
Hand-maintained: C3ArchTest.java parses this workspace but checks only its components; nothing on this view is compared with the code.
Hand-maintained: C3ArchTest.java parses this workspace but checks only its components; nothing on this view is compared with the code.
Its components and their arrows are checked against the code by C3ArchTest.java.
Its components and their arrows are checked against the code by C3ArchTest.java.
Its components and their arrows are checked against the code by C3ArchTest.java.
Hand-maintained: C3ArchTest.java parses this workspace but checks only its components; nothing on this view is compared with the code.
Code City
UX
19 of 19 screens changed · ⚠ +1 gap
How the screens were compared
human-review.json, each opened twice, side by side390f0e0e · branch Devoxx26 (a5a1f3fa)A screen counts as changed when

1 changed — nothing here to judge2 changed — nothing here to judge3 added: Items per page: 10 1 – 10 of 26

1 changed — nothing here to judge2 changed — nothing here to judge3 ✗ mat-paginator — added

1 changed — nothing here to judge2 changed — nothing here to judge3 changed — nothing here to judge
| side | element | role | why | delta | churn | |
|---|---|---|---|---|---|---|
| 3gap | Devoxx26 | Items per page: 10 1 – 10 of 26div#ownersTable> | paginator | not from the design system — <mat-paginator> is an Angular Material paginator, and the design system has no component for a paginator; its “Items per page” control is a <mat-select>, not combobrought in by this branch, from outside the design system | added | — |
why An Angular Material paginator ( | ||||||
#lastName — role input[type=text], not covered
1 changed — nothing here to judge2 changed — nothing here to judge

1 changed — nothing here to judge2 changed — nothing here to judge

1 changed — nothing here to judge2 changed — nothing here to judge
| side | element | role | why | delta | churn | |
|---|---|---|---|---|---|---|
| 3ok | Devoxx26 | Typeapp-combo[name="type"] | design-system component combo | same | 0% | |
markup and templateThe design system’s combo component ( | ||||||
| 3ok | 390f0e0e | Typeapp-combo[name="type"] | design-system component combo | same | 0% | |
markup and templateThe design system’s combo component ( | ||||||
#owner_name — role input[type=text], not covered#name — role input[type=text], not coveredinput[name="birthDate"] — role input[type=text], not covered
1 changed — nothing here to judge2 changed — nothing here to judge

1 changed — nothing here to judge2 changed — nothing here to judge

1 changed — nothing here to judge2 changed — nothing here to judge
| side | element | role | why | delta | churn | |
|---|---|---|---|---|---|---|
| 3ok | Devoxx26 | Typeapp-combo[name="pettype"] | design-system component combo | same | 0% | |
markup and templateThe design system’s combo component ( | ||||||
| 3ok | 390f0e0e | Typeapp-combo[name="pettype"] | design-system component combo | same | 0% | |
markup and templateThe design system’s combo component ( | ||||||
#owner_name — role input[type=text], not covered#name — role input[type=text], not coveredinput[name="birthDate"] — role input[type=text], not covered#type1 — role input[type=text], not covered
1 changed — nothing here to judge2 changed — nothing here to judge

1 changed — nothing here to judge2 changed — nothing here to judge

1 changed — nothing here to judge2 changed — nothing here to judge
| side | element | role | why | delta | churn | |
|---|---|---|---|---|---|---|
| 3ok | Devoxx26 | Typeapp-combo[name="specialties"] | design-system component combo | same | 0% | |
markup and templateThe design system’s combo component ( | ||||||
| 3ok | 390f0e0e | Typeapp-combo[name="specialties"] | design-system component combo | same | 0% | |
markup and templateThe design system’s combo component ( | ||||||
#firstName — role input[type=text], not covered#lastName — role input[type=text], not covered
1 changed — nothing here to judge2 changed — nothing here to judge

1 changed — nothing here to judge2 changed — nothing here to judge

1 changed — nothing here to judge2 changed — nothing here to judge
| side | element | role | why | delta | churn | |
|---|---|---|---|---|---|---|
| No control on this screen is a design-system component or fills a role one covers — nothing to judge here; what was considered is listed below. | ||||||

1 changed — nothing here to judge2 changed — nothing here to judge

1 changed — nothing here to judge2 changed — nothing here to judge

1 changed — nothing here to judge2 changed — nothing here to judge
| side | element | role | why | delta | churn | |
|---|---|---|---|---|---|---|
| No control on this screen is a design-system component or fills a role one covers — nothing to judge here; what was considered is listed below. | ||||||
#firstName — role input[type=text], not covered#lastName — role input[type=text], not covered#address — role input[type=text], not covered#city — role input[type=text], not covered#telephone — role input[type=text], not covered
1 changed — nothing here to judge2 changed — nothing here to judge

1 changed — nothing here to judge2 changed — nothing here to judge

1 changed — nothing here to judge2 changed — nothing here to judge
| side | element | role | why | delta | churn | |
|---|---|---|---|---|---|---|
| No control on this screen is a design-system component or fills a role one covers — nothing to judge here; what was considered is listed below. | ||||||

1 changed — nothing here to judge2 changed — nothing here to judge

1 changed — nothing here to judge2 changed — nothing here to judge

1 changed — nothing here to judge2 changed — nothing here to judge
| side | element | role | why | delta | churn | |
|---|---|---|---|---|---|---|
| No control on this screen is a design-system component or fills a role one covers — nothing to judge here; what was considered is listed below. | ||||||
#firstName — role input[type=text], not covered#lastName — role input[type=text], not covered#address — role input[type=text], not covered#city — role input[type=text], not covered#telephone — role input[type=text], not covered
1 changed — nothing here to judge2 changed — nothing here to judge

1 changed — nothing here to judge2 changed — nothing here to judge

1 changed — nothing here to judge2 changed — nothing here to judge
| side | element | role | why | delta | churn | |
|---|---|---|---|---|---|---|
| No control on this screen is a design-system component or fills a role one covers — nothing to judge here; what was considered is listed below. | ||||||

1 changed — nothing here to judge2 changed — nothing here to judge

1 changed — nothing here to judge2 changed — nothing here to judge

1 changed — nothing here to judge2 changed — nothing here to judge
| side | element | role | why | delta | churn | |
|---|---|---|---|---|---|---|
| No control on this screen is a design-system component or fills a role one covers — nothing to judge here; what was considered is listed below. | ||||||
input[name="date"] — role input[type=text], not covered#description — role input[type=text], not covered
1 changed — nothing here to judge2 changed — nothing here to judge

1 changed — nothing here to judge2 changed — nothing here to judge

1 changed — nothing here to judge2 changed — nothing here to judge
| side | element | role | why | delta | churn | |
|---|---|---|---|---|---|---|
| No control on this screen is a design-system component or fills a role one covers — nothing to judge here; what was considered is listed below. | ||||||

1 changed — nothing here to judge2 changed — nothing here to judge

1 changed — nothing here to judge2 changed — nothing here to judge

1 changed — nothing here to judge2 changed — nothing here to judge
| side | element | role | why | delta | churn | |
|---|---|---|---|---|---|---|
| No control on this screen is a design-system component or fills a role one covers — nothing to judge here; what was considered is listed below. | ||||||
input[name="date"] — role input[type=text], not covered#description — role input[type=text], not covered
1 changed — nothing here to judge2 changed — nothing here to judge

1 changed — nothing here to judge2 changed — nothing here to judge

1 changed — nothing here to judge2 changed — nothing here to judge
| side | element | role | why | delta | churn | |
|---|---|---|---|---|---|---|
| No control on this screen is a design-system component or fills a role one covers — nothing to judge here; what was considered is listed below. | ||||||
#\30 — role input[type=text], not covered#\31 — role input[type=text], not covered#\32 — role input[type=text], not covered#\33 — role input[type=text], not covered#\34 — role input[type=text], not covered#\35 — role input[type=text], not covered#\36 — role input[type=text], not covered
1 changed — nothing here to judge2 changed — nothing here to judge

1 changed — nothing here to judge2 changed — nothing here to judge

1 changed — nothing here to judge2 changed — nothing here to judge
| side | element | role | why | delta | churn | |
|---|---|---|---|---|---|---|
| No control on this screen is a design-system component or fills a role one covers — nothing to judge here; what was considered is listed below. | ||||||
#name — role input[type=text], not covered
1 changed — nothing here to judge2 changed — nothing here to judge

1 changed — nothing here to judge2 changed — nothing here to judge

1 changed — nothing here to judge2 changed — nothing here to judge
| side | element | role | why | delta | churn | |
|---|---|---|---|---|---|---|
| No control on this screen is a design-system component or fills a role one covers — nothing to judge here; what was considered is listed below. | ||||||
#name — role input[type=text], not covered
1 changed — nothing here to judge2 changed — nothing here to judge

1 changed — nothing here to judge2 changed — nothing here to judge

1 changed — nothing here to judge2 changed — nothing here to judge
| side | element | role | why | delta | churn | |
|---|---|---|---|---|---|---|
| No control on this screen is a design-system component or fills a role one covers — nothing to judge here; what was considered is listed below. | ||||||
#\30 — role input[type=text], not covered#\31 — role input[type=text], not covered#\32 — role input[type=text], not covered
1 changed — nothing here to judge2 changed — nothing here to judge

1 changed — nothing here to judge2 changed — nothing here to judge

1 changed — nothing here to judge2 changed — nothing here to judge
| side | element | role | why | delta | churn | |
|---|---|---|---|---|---|---|
| No control on this screen is a design-system component or fills a role one covers — nothing to judge here; what was considered is listed below. | ||||||
#name — role input[type=text], not covered#description — role textarea, not covered
1 changed — nothing here to judge2 changed — nothing here to judge

1 changed — nothing here to judge2 changed — nothing here to judge

1 changed — nothing here to judge2 changed — nothing here to judge
| side | element | role | why | delta | churn | |
|---|---|---|---|---|---|---|
| No control on this screen is a design-system component or fills a role one covers — nothing to judge here; what was considered is listed below. | ||||||

1 changed — nothing here to judge2 changed — nothing here to judge

1 changed — nothing here to judge2 changed — nothing here to judge

1 changed — nothing here to judge2 changed — nothing here to judge
| side | element | role | why | delta | churn | |
|---|---|---|---|---|---|---|
| No control on this screen is a design-system component or fills a role one covers — nothing to judge here; what was considered is listed below. | ||||||
#firstName — role input[type=text], not covered#lastName — role input[type=text], not covered#spec — role select[multiple], not coveredComplexity
Computed by endpoint-complexity.py with JavaParser and its symbol solver: a syntax tree of the Java sources, every call bound by type.
/api/users↗11/assistant↗11/api/owners/{ownerId}/pets/{petId}/visits↗8/api/visits↗4/api/visits/{visitId}↗4/jev↗3/api/pettypes↗2/history↗2/api/owners/{ownerId}/pets↗1/api/pets/{petId}↗1/api/pettypes/{petTypeId}↗1/api/specialties/feed↗1/↗0Nothing counted: every method behind this entry point is straight-line code. Cognitive complexity charges for branching, loops and boolean runs, and there are none here.
/api/owners↗0Nothing counted: every method behind this entry point is straight-line code. Cognitive complexity charges for branching, loops and boolean runs, and there are none here.
/api/owners/count↗0Nothing counted: every method behind this entry point is straight-line code. Cognitive complexity charges for branching, loops and boolean runs, and there are none here.
/api/owners/{ownerId}↗0Nothing counted: every method behind this entry point is straight-line code. Cognitive complexity charges for branching, loops and boolean runs, and there are none here.
/api/owners/{ownerId}↗0Nothing counted: every method behind this entry point is straight-line code. Cognitive complexity charges for branching, loops and boolean runs, and there are none here.
/api/owners/{ownerId}/pets/{petId}↗0Nothing counted: every method behind this entry point is straight-line code. Cognitive complexity charges for branching, loops and boolean runs, and there are none here.
/api/pets/{petId}↗0Nothing counted: every method behind this entry point is straight-line code. Cognitive complexity charges for branching, loops and boolean runs, and there are none here.
/api/pettypes↗0Nothing counted: every method behind this entry point is straight-line code. Cognitive complexity charges for branching, loops and boolean runs, and there are none here.
/api/pettypes/{petTypeId}↗0Nothing counted: every method behind this entry point is straight-line code. Cognitive complexity charges for branching, loops and boolean runs, and there are none here.
/api/pettypes/{petTypeId}↗0Nothing counted: every method behind this entry point is straight-line code. Cognitive complexity charges for branching, loops and boolean runs, and there are none here.
/api/specialties↗0Nothing counted: every method behind this entry point is straight-line code. Cognitive complexity charges for branching, loops and boolean runs, and there are none here.
/api/specialties/{specialtyId}↗0Nothing counted: every method behind this entry point is straight-line code. Cognitive complexity charges for branching, loops and boolean runs, and there are none here.
/api/specialties/{specialtyId}↗0Nothing counted: every method behind this entry point is straight-line code. Cognitive complexity charges for branching, loops and boolean runs, and there are none here.
/api/specialties/{specialtyId}↗0Nothing counted: every method behind this entry point is straight-line code. Cognitive complexity charges for branching, loops and boolean runs, and there are none here.
/api/vets/{vetId}↗0Nothing counted: every method behind this entry point is straight-line code. Cognitive complexity charges for branching, loops and boolean runs, and there are none here.
/api/visits/{visitId}↗0Nothing counted: every method behind this entry point is straight-line code. Cognitive complexity charges for branching, loops and boolean runs, and there are none here.
/firefighter↗0Nothing counted: every method behind this entry point is straight-line code. Cognitive complexity charges for branching, loops and boolean runs, and there are none here.
/history↗0Nothing counted: every method behind this entry point is straight-line code. Cognitive complexity charges for branching, loops and boolean runs, and there are none here.
/model↗0Nothing counted: every method behind this entry point is straight-line code. Cognitive complexity charges for branching, loops and boolean runs, and there are none here.
call_vet_ambulance↗6if (context == null || !context.elicitEnabled()) {+1if (context == null || !context.elicitEnabled()) {+1if (elicit.action() != ElicitResult.Action.ACCEPT) {+1String address = input == null ? null : input.address();+1if (address == null || address.isBlank()) {+1if (address == null || address.isBlank()) {+1LocalTools.sendEmail↗2cancel_visit↗2get_owner_profile↗1RagIngestion.poll↗17Logging
Found by syntax-aware search over Java sources
None. Not one logging statement was added or changed on the lines this change set touches.
CODEOWNERS
Cost
| step | you | agent | cost | ||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
ImplementationClaude Code 8782fda6 | 7 min | 36 min | $15.98Opus 5.5 | ||||||||||||||||||||||||||||||||
ReviewClaude Code 8782fda6 · the reviewers and their brief | 19 s | 16 min | $5.67Opus 5.5 | ||||||||||||||||||||||||||||||||
Auto-fixesClaude Code 8782fda6 · deciding and fixing | 2 min | 58 min | $9.73Opus 5.5 · Sonnet 5.5 | ||||||||||||||||||||||||||||||||
| claude -p on Sonnet 5.5 · requirements↔tests mappingplus 15 min of later refreshes, no model | — | 2 min | $0.39Sonnet 5.5 | ||||||||||||||||||||||||||||||||
| |||||||||||||||||||||||||||||||||||
| VoicesFish Audio, 2 voices | < $0.01s2.1-pro-free | ||||||||||||||||||||||||||||||||||
| Total | 9 min | 1 h 52 min | $31.77 | ||||||||||||||||||||||||||||||||
Building human-review itself — the tool, not this change
186 sessions, 27 Aug 2026 → 8 Oct 2026