Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions contributions/localizedStrings.json
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@
"%interlinearizer_tokenChip_addToPhrase%": "Add \"{token}\" to phrase",
"%interlinearizer_suggestion_accept%": "Accept suggestion {gloss} for {token}",
"%interlinearizer_suggestion_promote%": "Promote {gloss} for {token}",
"%interlinearizer_suggestion_breakdown%": "broken down as {breakdown}",
"%interlinearizer_linkButton_crossSegmentDisabledTooltip%": "Words in different segments can't be linked. Join the segments first to link across this boundary.",
"%interlinearizer_linkButton_link%": "Link words",
"%interlinearizer_linkButton_unlink%": "Unlink words",
Expand Down
75 changes: 75 additions & 0 deletions src/__tests__/components/TokenChip.suggestions.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,55 @@ function homographBankPool(financeGloss: string | undefined): TextAnalysis {
return { ...emptyAnalysis(), tokenAnalyses: [river, fin], tokenAnalysisLinks: links };
}

/**
* Builds a pool where 'ran' carries two approved analyses glossed alike, the more frequent parsed
* as `run PST` and the other broken down into `rivalForms`.
*/
function sameGlossParsePool(rivalForms: readonly string[]): TextAnalysis {
const pastTense: TokenAnalysis = {
...FIXTURE_STAMPS,
id: 'ta-past',
surfaceText: 'ran',
gloss: { en: 'ran' },
morphemes: [
{ id: 'm-run', form: 'run', writingSystem: 'und' },
{ id: 'm-pst', form: 'PST', writingSystem: 'und' },
],
};
const rival: TokenAnalysis = {
...FIXTURE_STAMPS,
id: 'ta-rival',
surfaceText: 'ran',
gloss: { en: 'ran' },
morphemes: rivalForms.map((form, i) => ({
id: `m-rival-${i}`,
form,
writingSystem: 'und',
})),
};
const links: TokenAnalysisLink[] = [
{
...FIXTURE_STAMPS,
analysisId: 'ta-past',
status: 'approved',
token: { tokenRef: 'p1', surfaceText: 'ran' },
},
{
...FIXTURE_STAMPS,
analysisId: 'ta-past',
status: 'approved',
token: { tokenRef: 'p2', surfaceText: 'ran' },
},
{
...FIXTURE_STAMPS,
analysisId: 'ta-rival',
status: 'approved',
token: { tokenRef: 'v1', surfaceText: 'ran' },
},
];
return { ...emptyAnalysis(), tokenAnalyses: [pastTense, rival], tokenAnalysisLinks: links };
}

/**
* Builds the homograph 'bank' where the MOST-frequent analysis has no active-language (English)
* gloss — only French — and a lower-frequency one carries `en:'finance'`. Exercises falling through
Expand Down Expand Up @@ -409,6 +458,32 @@ describe('TokenChip suggestion dropdown', () => {
expect(link?.status).toBe('approved');
});

it('shows each breakdown when two suggestions share one gloss', async () => {
renderChip(makeWordToken('tok-new', 'ran'), {
initialAnalysis: sameGlossParsePool(['ran']),
});

await focusGloss();

expect(screen.getByTestId('suggestion-accept')).toHaveTextContent('run PST');
expect(screen.getByTestId('suggestion-candidate')).toHaveTextContent('ran');
expect(screen.getAllByTestId('suggestion-breakdown').map((el) => el.textContent)).toEqual([
'run PST',
'ran',
]);
});

it('omits the breakdown on a suggestion with no morphological breakdown', async () => {
// The 'bank' pool analyses carry no morphemes, so there is no breakdown for either row to show.
renderChip(makeWordToken('tok-new', 'bank'), {
initialAnalysis: homographBankPool('finance'),
});

await focusGloss();

expect(screen.queryByTestId('suggestion-breakdown')).not.toBeInTheDocument();
});

it('omits a candidate that has no gloss in the active language', async () => {
renderChip(makeWordToken('tok-new', 'bank'), { initialAnalysis: homographBankPool(undefined) });

Expand Down
81 changes: 81 additions & 0 deletions src/__tests__/utils/suggestion-engine.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import type { ResolvedTokenAnalysis } from '../../utils/suggestion-engine';
import {
buildPoolIndex,
deriveTokenSuggestion,
glossedSuggestionEntries,
resolvedTokenAnalysisEqual,
} from '../../utils/suggestion-engine';

Expand Down Expand Up @@ -335,3 +336,83 @@ describe('resolvedTokenAnalysisEqual', () => {
).toBe(false);
});
});

describe('glossedSuggestionEntries breakdowns', () => {
/** Builds an analysis of 'ran' glossed as `gloss` and broken down into `forms`. */
function parsed(id: string, gloss: string, forms: readonly string[]): TokenAnalysis {
return {
...FIXTURE_STAMPS,
id,
surfaceText: 'ran',
gloss: { en: gloss },
morphemes: forms.map((form, i) => ({ id: `${id}-m${i}`, form, writingSystem: 'und' })),
};
}

it('carries each row its own breakdown, which is what tells same-gloss rows apart', () => {
const pastTense = parsed('p1', 'ran', ['run', 'PST']);
const bareRoot = parsed('p2', 'ran', ['ran']);

const entries = glossedSuggestionEntries(
{ status: 'suggested', suggested: pastTense, candidates: [bareRoot] },
'en',
);

expect(entries).toEqual([
{ id: 'p1', gloss: 'ran', status: 'suggested', breakdown: 'run PST' },
{ id: 'p2', gloss: 'ran', status: 'candidate', breakdown: 'ran' },
]);
});

it('omits the breakdown on a row whose payload carries no morphemes at all', () => {
// Printing a blank annotation would read as missing data rather than as "nothing to show".
const unparsed: TokenAnalysis = {
...FIXTURE_STAMPS,
id: 'w1',
surfaceText: 'ran',
gloss: { en: 'ran' },
};
const parsedRival = parsed('w2', 'ran', ['run', 'PST']);

const entries = glossedSuggestionEntries(
{ status: 'suggested', suggested: unparsed, candidates: [parsedRival] },
'en',
);

expect(entries).toEqual([
{ id: 'w1', gloss: 'ran', status: 'suggested' },
{ id: 'w2', gloss: 'ran', status: 'candidate', breakdown: 'run PST' },
]);
});

it('omits the breakdown on a row whose morpheme list is empty', () => {
const emptyParse: TokenAnalysis = { ...parsed('e1', 'ran', []), morphemes: [] };

const entries = glossedSuggestionEntries(
{ status: 'suggested', suggested: emptyParse, candidates: [] },
'en',
);

expect(entries).toEqual([{ id: 'e1', gloss: 'ran', status: 'suggested' }]);
});

it('carries breakdowns on an approved token, which offers only promotions', () => {
const approved = parsed('a1', 'went', ['go', 'PST']);
const first = parsed('a2', 'ran', ['run', 'PST']);
const second = parsed('a3', 'ran', ['ran']);

const entries = glossedSuggestionEntries(
{
status: 'approved',
analysis: approved,
poolSuggestion: { suggested: approved, candidates: [first, second] },
},
'en',
);

expect(entries).toEqual([
{ id: 'a2', gloss: 'ran', status: 'candidate', breakdown: 'run PST' },
{ id: 'a3', gloss: 'ran', status: 'candidate', breakdown: 'ran' },
]);
});
});
3 changes: 3 additions & 0 deletions src/components/PhraseStripContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ export type TokenChipLabels = Readonly<{
acceptSuggestion: string;
/** Accessible label for a dropdown row that promotes a candidate gloss. */
promoteSuggestion: string;
/** Accessible suffix naming the morpheme breakdown a suggestion dropdown row is showing. */
suggestionBreakdown: string;
}>;

/**
Expand All @@ -41,6 +43,7 @@ export const TOKEN_CHIP_LABEL_KEYS = {
morphemeGloss: '%interlinearizer_morphemeGloss_label%',
acceptSuggestion: '%interlinearizer_suggestion_accept%',
promoteSuggestion: '%interlinearizer_suggestion_promote%',
suggestionBreakdown: '%interlinearizer_suggestion_breakdown%',
} as const satisfies Record<keyof TokenChipLabels, `%${string}%`>;

/**
Expand Down
37 changes: 32 additions & 5 deletions src/components/SuggestionDropdown.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,12 @@ type SuggestionDropdownProps = Readonly<{
acceptLabelTemplate: string;
/** Same as {@link acceptLabelTemplate}, for a "promote this candidate gloss" row. */
promoteLabelTemplate: string;
/** Surface form of the token being glossed, filling the `{token}` placeholder in both templates. */
/**
* Accessible suffix naming a row's morpheme breakdown, with `{breakdown}` still to fill in.
* Appended to the label of each row carrying one, so same-gloss rows do not sound identical.
*/
breakdownLabelTemplate: string;
/** Surface form of the token being glossed, filling `{token}` in the accept and promote templates. */
tokenSurfaceText: string;
/** Called with a row index when the pointer enters it, so hover and keyboard share one highlight. */
onActiveIndexChange: (index: number) => void;
Expand All @@ -43,6 +48,9 @@ type SuggestionDropdownProps = Readonly<{
* Each row is colored and labeled by its own `status` — `'suggested'` (blue, "accept") or
* `'candidate'` (gray, "promote") — carried on the entry rather than inferred from position, so a
* dropped blank-in-language pick can never leave a candidate masquerading as the accept row.
*
* A row also renders its `breakdown` when it carries one, so two analyses glossed alike are never
* offered as identical choices.
*/
export default function SuggestionDropdown({
listboxId,
Expand All @@ -51,6 +59,7 @@ export default function SuggestionDropdown({
activeIndex,
acceptLabelTemplate,
promoteLabelTemplate,
breakdownLabelTemplate,
tokenSurfaceText,
onActiveIndexChange,
onSelect,
Expand Down Expand Up @@ -99,10 +108,17 @@ export default function SuggestionDropdown({
{entries.map((entry, index) => (
<div
key={entry.id}
aria-label={formatReplacementString(
entry.status === 'suggested' ? acceptLabelTemplate : promoteLabelTemplate,
{ gloss: entry.gloss, token: tokenSurfaceText },
)}
aria-label={
formatReplacementString(
entry.status === 'suggested' ? acceptLabelTemplate : promoteLabelTemplate,
{ gloss: entry.gloss, token: tokenSurfaceText },
) +
(entry.breakdown === undefined
? ''
: `, ${formatReplacementString(breakdownLabelTemplate, {
breakdown: entry.breakdown,
})}`)
}
aria-selected={index === activeIndex}
className={`tw:cursor-pointer tw:whitespace-nowrap tw:px-3 tw:py-0.5 tw:text-sm tw:italic ${STATUS_TEXT_COLOR_CLASS[entry.status]}${index === activeIndex ? ' tw:bg-accent' : ''}`}
data-testid={entry.status === 'suggested' ? 'suggestion-accept' : 'suggestion-candidate'}
Expand All @@ -121,6 +137,17 @@ export default function SuggestionDropdown({
onMouseEnter={() => onActiveIndexChange(index)}
>
{entry.gloss}
{entry.breakdown !== undefined && (
// Hidden from assistive tech because the row's own label already speaks the breakdown,
// which would otherwise be announced twice.
<span
aria-hidden
className="tw:ms-2 tw:text-xs tw:not-italic tw:text-muted-foreground"
data-testid="suggestion-breakdown"
>
{entry.breakdown}
</span>
)}
</div>
))}
</PopoverContent>
Expand Down
1 change: 1 addition & 0 deletions src/components/TokenChip.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -576,6 +576,7 @@ export function TokenChip({
listboxId={listboxId}
optionId={optionId}
acceptLabelTemplate={labels.acceptSuggestion}
breakdownLabelTemplate={labels.suggestionBreakdown}
promoteLabelTemplate={labels.promoteSuggestion}
tokenSurfaceText={token.surfaceText}
onActiveIndexChange={setActiveIndex}
Expand Down
3 changes: 3 additions & 0 deletions src/hooks/usePhraseStripSetup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,7 @@ function useTokenChipLabels(): TokenChipLabels {
const morphemeGloss = strings[TOKEN_CHIP_LABEL_KEYS.morphemeGloss];
const acceptSuggestion = strings[TOKEN_CHIP_LABEL_KEYS.acceptSuggestion];
const promoteSuggestion = strings[TOKEN_CHIP_LABEL_KEYS.promoteSuggestion];
const suggestionBreakdown = strings[TOKEN_CHIP_LABEL_KEYS.suggestionBreakdown];

return useMemo(
() => ({
Expand All @@ -113,6 +114,7 @@ function useTokenChipLabels(): TokenChipLabels {
morphemeGloss,
acceptSuggestion,
promoteSuggestion,
suggestionBreakdown,
}),
[
tokenGloss,
Expand All @@ -122,6 +124,7 @@ function useTokenChipLabels(): TokenChipLabels {
morphemeGloss,
acceptSuggestion,
promoteSuggestion,
suggestionBreakdown,
],
);
}
Expand Down
46 changes: 28 additions & 18 deletions src/utils/suggestion-engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -157,10 +157,7 @@ export function deriveTokenSuggestion(
};
}

/**
* One renderable suggestion entry: a payload id, its gloss in the active language, and the
* assignment status the UI colors and labels it by.
*/
/** One renderable suggestion entry: a matching payload reduced to what the gloss UI shows of it. */
export interface GlossedSuggestionEntry {
/** The matching payload's id — the approve/promote target and the React key. */
id: string;
Expand All @@ -173,29 +170,40 @@ export interface GlossedSuggestionEntry {
* masquerading as the accept row.
*/
status: Extract<AssignmentStatus, 'suggested' | 'candidate'>;
/**
* The payload's morpheme forms, rendered beside the gloss as context for the choice and to tell
* this row from another sharing its gloss. Absent when the payload has no morphological
* breakdown.
*/
breakdown?: string;
}

/** `undefined` when the payload has no morphological breakdown, so there are no forms to show. */
function breakdownOf(analysis: TokenAnalysis): string | undefined {
const { morphemes } = analysis;
if (!morphemes || morphemes.length === 0) return undefined;
return morphemes.map((morpheme) => morpheme.form).join(' ');
}

/**
* Flattens the merged per-token read into the entries the gloss UI renders, in rank order, keeping
* only those with a non-blank gloss in the active language.
*
* This is the single home of suggestion-presentation policy — which matches are renderable, how a
* blank-in-active-language pick falls through, the approved payload's exclusion from its own
* promote list, and each row's assignment status — so every surface ranks, colors, and labels
* suggestions identically instead of re-deriving any of it from row position.
* Single home of suggestion-presentation policy, so every surface offers the same rows rather than
* re-deriving any of it from row position; {@link GlossedSuggestionEntry} documents what each row
* carries. At most one entry is `'suggested'`, and often none (an approved token offers only
* promotions), so read `status` rather than assuming the first row is the accept row.
*
* Status is assigned _after_ blank picks are dropped. So when the engine's top pick has no gloss in
* the active language, the next-ranked glossed match becomes the accept row rather than the whole
* suggestion vanishing. An already-approved token has no accept row at all: every pool peer is a
* promotion, so even the top row reads as one.
* suggestion vanishing. An approved token's own payload is excluded from its promote list, leaving
* only genuine alternatives.
*/
export function glossedSuggestionEntries(
resolved: ResolvedTokenAnalysis | undefined,
analysisLanguage: string,
): GlossedSuggestionEntry[] {
if (!resolved) return [];
// The ranked payloads to offer, best-first. For an approved token its own payload is excluded so
// only genuine alternatives remain; for an un-approved token the engine's pick leads.
let ranked: readonly TokenAnalysis[];
if (resolved.status === 'suggested') {
ranked = [resolved.suggested, ...resolved.candidates];
Expand All @@ -205,16 +213,18 @@ export function glossedSuggestionEntries(
ranked = [pool.suggested, ...pool.candidates].filter((a) => a.id !== resolved.analysis.id);
}
const glossed = ranked
.map((analysis) => ({ id: analysis.id, gloss: analysis.gloss?.[analysisLanguage] ?? '' }))
.map((analysis) => ({
id: analysis.id,
gloss: analysis.gloss?.[analysisLanguage] ?? '',
breakdown: breakdownOf(analysis),
}))
.filter((entry) => entry.gloss !== '');
// Assign status by post-filter rank: only an un-approved token has an "accept" row (its top
// renderable match); an approved token offers only promotions. Done after the blank filter so a
// dropped top pick promotes the next-ranked glossed match to the accept row rather than leaving a
// candidate masquerading as it.
const hasAccept = resolved.status === 'suggested';
return glossed.map((entry, index) => ({
...entry,
id: entry.id,
gloss: entry.gloss,
status: hasAccept && index === 0 ? 'suggested' : 'candidate',
...(entry.breakdown === undefined ? {} : { breakdown: entry.breakdown }),
}));
}

Expand Down
Loading