Skip to content

Commit 677bfbd

Browse files
Merge branch 'main' into jonathanseehagen/feature/411-cross-granularity-playback
2 parents aee6199 + 0183cfb commit 677bfbd

10 files changed

Lines changed: 116 additions & 26 deletions

File tree

.github/workflows/maestro-android.yml

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,8 @@ jobs:
9898
CI: 'true'
9999

100100
# Assemble inside emulator-runner so ANDROID_HOME / SDK from the action are available.
101+
# android-emulator-runner invokes script with /usr/bin/sh (dash on ubuntu) — run the
102+
# body via a bash heredoc so `pipefail` and ${HOME} expansion work (#510).
101103
- name: Build APK + run Maestro on emulator
102104
uses: reactivecircus/android-emulator-runner@1dcd0090116d15e7c562f8db72807de5e036a4ed # v2.34.0
103105
env:
@@ -111,10 +113,10 @@ jobs:
111113
arch: x86_64
112114
profile: pixel_6
113115
disable-animations: true
114-
emulator-options: -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim
116+
emulator-options: -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -no-metrics
115117
emulator-boot-timeout: 900
116118
script: |
117-
set -euo pipefail
119+
bash -euo pipefail <<'SCRIPT'
118120
export PATH="${HOME}/.maestro/bin:${PATH}"
119121
chmod +x android/gradlew
120122
(
@@ -142,6 +144,7 @@ jobs:
142144
;;
143145
esac
144146
adb logcat -d > .maestro/test_output/logcat.txt || true
147+
SCRIPT
145148
146149
- name: Upload Maestro artifacts
147150
if: always()

docs/guides/maestro.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,8 @@ Opt-in **Android-only** Maestro suite for Fluent Mobile ([#488](https://github.c
4545

4646
Dispatch: **Actions → Maestro Android (informational) → Run workflow** (after the workflow exists on the target branch / `main`). Artifacts: JUnit + Maestro output + APK (14-day retention).
4747

48+
Note: `android-emulator-runner` runs its `script` under `/usr/bin/sh` (dash on Ubuntu). The workflow runs the body through a `bash -euo pipefail` heredoc so bashisms like `pipefail` work (#510).
49+
4850
See also [docs/ci.md](../ci.md) and [`.github/README.md`](../../.github/README.md).
4951

5052
## Prerequisites

src/components/ui/MyWorkRow.test.tsx

Lines changed: 14 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -75,8 +75,8 @@ describe('MyWorkRow', () => {
7575
expect(queryByTestId('chapter-conflict-indicator')).toBeNull();
7676
});
7777

78-
it('renders indicators in the shared cloud, conflict, ownership order', () => {
79-
const { getAllByTestId } = render(
78+
it('shows only the conflict indicator when both conflict and ownership apply', () => {
79+
const { getAllByTestId, queryByTestId } = render(
8080
<MyWorkRow
8181
chapter={{ ...baseChapter, hasConflict: true }}
8282
onPress={jest.fn()}
@@ -85,10 +85,17 @@ describe('MyWorkRow', () => {
8585

8686
expect(
8787
getAllByTestId(/-indicator$/).map(node => node.props.testID),
88-
).toEqual([
89-
'chapter-cloud-sync-indicator',
90-
'chapter-conflict-indicator',
91-
'chapter-ownership-indicator',
92-
]);
88+
).toEqual(['chapter-cloud-sync-indicator', 'chapter-conflict-indicator']);
89+
expect(queryByTestId('chapter-ownership-indicator')).toBeNull();
90+
});
91+
92+
it('shows the ownership indicator when there is no conflict', () => {
93+
const { getAllByTestId } = render(
94+
<MyWorkRow chapter={baseChapter} onPress={jest.fn()} />,
95+
);
96+
97+
expect(
98+
getAllByTestId(/-indicator$/).map(node => node.props.testID),
99+
).toEqual(['chapter-cloud-sync-indicator', 'chapter-ownership-indicator']);
93100
});
94101
});

src/components/ui/MyWorkRow.tsx

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -38,8 +38,13 @@ export function MyWorkRow({
3838
{display.showCloudSync ? (
3939
<ChapterCloudSyncIndicator syncState={chapter.syncState} />
4040
) : null}
41-
{chapter.hasConflict ? <ChapterConflictIndicator /> : null}
42-
<ChapterOwnershipIndicator ownershipState={chapter.ownershipState} />
41+
{chapter.hasConflict ? (
42+
<ChapterConflictIndicator />
43+
) : (
44+
<ChapterOwnershipIndicator
45+
ownershipState={chapter.ownershipState}
46+
/>
47+
)}
4348
</View>
4449

4550
<View style={styles.metaRow}>

src/components/ui/ProjectChapterRow.test.tsx

Lines changed: 14 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -75,8 +75,8 @@ describe('ProjectChapterRow', () => {
7575
expect(queryByTestId('chapter-conflict-indicator')).toBeNull();
7676
});
7777

78-
it('renders indicators in the shared cloud, conflict, ownership order', () => {
79-
const { getAllByTestId } = render(
78+
it('shows only the conflict indicator when both conflict and ownership apply', () => {
79+
const { getAllByTestId, queryByTestId } = render(
8080
<ProjectChapterRow
8181
chapter={{ ...baseChapter, hasConflict: true }}
8282
onPress={jest.fn()}
@@ -85,10 +85,17 @@ describe('ProjectChapterRow', () => {
8585

8686
expect(
8787
getAllByTestId(/-indicator$/).map(node => node.props.testID),
88-
).toEqual([
89-
'chapter-cloud-sync-indicator',
90-
'chapter-conflict-indicator',
91-
'chapter-ownership-indicator',
92-
]);
88+
).toEqual(['chapter-cloud-sync-indicator', 'chapter-conflict-indicator']);
89+
expect(queryByTestId('chapter-ownership-indicator')).toBeNull();
90+
});
91+
92+
it('shows the ownership indicator when there is no conflict', () => {
93+
const { getAllByTestId } = render(
94+
<ProjectChapterRow chapter={baseChapter} onPress={jest.fn()} />,
95+
);
96+
97+
expect(
98+
getAllByTestId(/-indicator$/).map(node => node.props.testID),
99+
).toEqual(['chapter-cloud-sync-indicator', 'chapter-ownership-indicator']);
93100
});
94101
});

src/components/ui/ProjectChapterRow.tsx

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -49,11 +49,12 @@ export function ProjectChapterRow({
4949
) : null}
5050
{chapter.hasConflict ? (
5151
<ChapterConflictIndicator size={iconSizes.projectSync} />
52-
) : null}
53-
<ChapterOwnershipIndicator
54-
ownershipState={chapter.ownershipState}
55-
size={iconSizes.projectSync}
56-
/>
52+
) : (
53+
<ChapterOwnershipIndicator
54+
ownershipState={chapter.ownershipState}
55+
size={iconSizes.projectSync}
56+
/>
57+
)}
5758
</View>
5859

5960
<View style={styles.metaRow}>

src/db/queries.ts

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,10 @@ import {
1414
MY_WORK_CHAPTER_WHERE,
1515
} from '../utils/myWorkChapterFilter';
1616
import { getBadgeStage, getWorkflowStage } from '../utils/workflowStage';
17-
import { deriveChapterOwnershipState } from '../utils/chapterOwnershipState';
17+
import {
18+
deriveChapterOwnershipState,
19+
resolveStageAssigneeId,
20+
} from '../utils/chapterOwnershipState';
1821

1922
function parseConnectivityProfile(
2023
metadata: string | null,
@@ -450,6 +453,7 @@ function mapChapterRowCore(
450453
| 'id'
451454
| 'book_name'
452455
| 'chapter_number'
456+
| 'status'
453457
| 'updated_at'
454458
| 'submitted_time'
455459
| 'last_recording_activity'
@@ -459,6 +463,7 @@ function mapChapterRowCore(
459463
| 'completed_verses'
460464
| 'downloaded_verses'
461465
| 'assigned_user_id'
466+
| 'peer_checker_id'
462467
| 'has_conflict'
463468
>,
464469
currentUserId: number,
@@ -474,7 +479,11 @@ function mapChapterRowCore(
474479
chapterNumber: row.chapter_number,
475480
syncState: deriveChapterSyncState(recordingCount, pendingCount),
476481
ownershipState: deriveChapterOwnershipState(
477-
row.assigned_user_id,
482+
resolveStageAssigneeId(
483+
row.status,
484+
row.assigned_user_id,
485+
row.peer_checker_id,
486+
),
478487
currentUserId,
479488
),
480489
lastActivityAt: activity.lastActivityAt,
@@ -523,6 +532,7 @@ export async function getProjectChapters(
523532
ca.updated_at,
524533
ca.submitted_time,
525534
ca.assigned_user_id,
535+
ca.peer_checker_id,
526536
b.eng_display_name AS book_name,
527537
${RECORDING_AGGREGATES},
528538
(
@@ -571,6 +581,7 @@ export async function getMyWorkChapters(
571581
ca.updated_at,
572582
ca.submitted_time,
573583
ca.assigned_user_id,
584+
ca.peer_checker_id,
574585
b.eng_display_name AS book_name,
575586
p.name AS project_name,
576587
tl.lang_name AS target_language_name,

src/types/db/types.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,7 @@ export interface ProjectChapterRow {
9090
completed_verses: number;
9191
downloaded_verses: number;
9292
assigned_user_id?: number | null;
93+
peer_checker_id?: number | null;
9394
has_conflict: number;
9495
}
9596

@@ -110,6 +111,7 @@ export interface MyWorkChapterRow {
110111
completed_verses: number;
111112
downloaded_verses: number;
112113
assigned_user_id?: number | null;
114+
peer_checker_id?: number | null;
113115
has_conflict: number;
114116
}
115117

src/utils/chapterOwnershipState.test.ts

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,7 @@
1-
import { deriveChapterOwnershipState } from './chapterOwnershipState';
1+
import {
2+
deriveChapterOwnershipState,
3+
resolveStageAssigneeId,
4+
} from './chapterOwnershipState';
25

36
describe('deriveChapterOwnershipState', () => {
47
it('returns unassigned when there is no assigned user', () => {
@@ -15,3 +18,34 @@ describe('deriveChapterOwnershipState', () => {
1518
expect(deriveChapterOwnershipState(42, null)).toBe('other');
1619
});
1720
});
21+
22+
describe('resolveStageAssigneeId', () => {
23+
it('returns the drafter at Drafting (draft or not_started)', () => {
24+
expect(resolveStageAssigneeId('draft', 42, 99)).toBe(42);
25+
expect(resolveStageAssigneeId('not_started', 42, 99)).toBe(42);
26+
});
27+
28+
it('returns null at Drafting when no drafter is assigned', () => {
29+
expect(resolveStageAssigneeId('not_started', null, 99)).toBeNull();
30+
});
31+
32+
it('returns the PM-assigned peer checker at Peer Check', () => {
33+
expect(resolveStageAssigneeId('peer_check', 42, 99)).toBe(99);
34+
});
35+
36+
it('returns null/undefined at Peer Check when open/unassigned (#442)', () => {
37+
expect(resolveStageAssigneeId('peer_check', 42, null)).toBeNull();
38+
expect(resolveStageAssigneeId('peer_check', 42, undefined)).toBeUndefined();
39+
});
40+
41+
it('returns null for stages with no assignee concept', () => {
42+
expect(resolveStageAssigneeId('community_review', 42, 99)).toBeNull();
43+
expect(resolveStageAssigneeId('consultant_check', 42, 99)).toBeNull();
44+
expect(resolveStageAssigneeId('complete', 42, 99)).toBeNull();
45+
});
46+
47+
it('returns null for unrecognized or null status', () => {
48+
expect(resolveStageAssigneeId('some_future_status', 42, 99)).toBeNull();
49+
expect(resolveStageAssigneeId(null, 42, 99)).toBeNull();
50+
});
51+
});

src/utils/chapterOwnershipState.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { getWorkflowStage } from './workflowStage';
12
import { ChapterOwnershipState } from '../types/db/types';
23

34
export function deriveChapterOwnershipState(
@@ -9,3 +10,20 @@ export function deriveChapterOwnershipState(
910
if (currentUserId !== null && assignedUserId === currentUserId) return 'mine';
1011
return 'other';
1112
}
13+
14+
export function resolveStageAssigneeId(
15+
status: string | null | undefined,
16+
assignedUserId: number | null | undefined,
17+
peerCheckerId: number | null | undefined,
18+
): number | null | undefined {
19+
const stage = getWorkflowStage(status);
20+
switch (stage) {
21+
case 'draft':
22+
case 'not_started':
23+
return assignedUserId;
24+
case 'peer_check':
25+
return peerCheckerId;
26+
default:
27+
return null;
28+
}
29+
}

0 commit comments

Comments
 (0)