Show all active transition blocks inline + gate on isActual (TIRREDESIGN-7)

The inline expanded flight card used to pick one of boarding /
deboarding based on the search direction and show just that block.
Angular's board-flight-body renders registration, boarding and
deboarding side-by-side, each gated on the API payload's isActual flag
— TIRREDESIGN-7 expects the same so a flight mid-boarding can still
show that its registration already finished.

- FlightCard now iterates registration/boarding/deboarding and renders
  each row when its isActual flag is set. Gate + dispatch still come
  from depStation on boarding, gate + bag-belt from arrStation on
  deboarding.
- shared.shouldShowTransition swaps 'status != Scheduled' for the
  isActual flag to match Angular's same-payload semantics on the
  details accordion. The Schedule/Cancelled short-circuits stand.
- Test fixture makeFlightWithBoarding scopes its transition to the
  direction under test so the two blocks don't collide on testids.
This commit is contained in:
2026-04-22 13:55:53 +03:00
parent 31751d0e84
commit 7cc0327a12
4 changed files with 128 additions and 99 deletions
@@ -27,12 +27,16 @@ describe("shouldShowTransition", () => {
expect(shouldShowTransition(undefined, "Scheduled", "Onlineboard")).toBe(false);
});
it("returns false when item.status is Scheduled", () => {
const scheduled = { ...validItem, status: "Scheduled" as const };
expect(shouldShowTransition(scheduled, "Scheduled", "Onlineboard")).toBe(false);
// TIRREDESIGN-7: the rule now reads the API's isActual flag rather
// than inferring from status, so a transition marked inactive hides
// even if its status has left Scheduled (e.g. a cancelled boarding
// phase that hasn't been cleared to Scheduled).
it("returns false when item.isActual is false", () => {
const inactive = { ...validItem, isActual: false };
expect(shouldShowTransition(inactive, "Scheduled", "Onlineboard")).toBe(false);
});
it("returns true for active transition on Onlineboard", () => {
it("returns true when item.isActual is true on Onlineboard", () => {
expect(shouldShowTransition(validItem, "Scheduled", "Onlineboard")).toBe(true);
});
});
@@ -8,9 +8,14 @@ import type {
export type DetailsViewType = "Onlineboard" | "Schedule";
/**
* Matches Angular's `showBoardProperty` in flight-details-wrapper.component.ts.
* Transition panels are hidden for Schedule mode, Cancelled flights,
* missing data, or when the transition hasn't started.
* Matches Angular's `showBoardProperty` in flight-details-wrapper.component.ts,
* tightened for TIRREDESIGN-7 to use the payload's `isActual` flag. The
* API sets `isActual=true` precisely when a transition block is in its
* current operational phase — the backend computes that from status +
* clocked times, so consumers shouldn't rediscover it locally.
*
* Transition panels remain hidden for Schedule mode or Cancelled legs
* regardless of the flag (those contexts never show transition detail).
*/
export function shouldShowTransition(
item: IFlightTransitionItem | undefined,
@@ -20,8 +25,7 @@ export function shouldShowTransition(
if (viewType === "Schedule") return false;
if (legStatus === "Cancelled") return false;
if (!item) return false;
if (item.status === "Scheduled") return false;
return true;
return item.isActual === true;
}
/**
+18 -10
View File
@@ -504,28 +504,36 @@ function makeFlightWithBoarding(opts: {
gate?: string;
dispatch?: string;
bagBelt?: string;
/**
* Which transition phase to simulate as isActual. Both blocks now
* render side-by-side when both are active (TIRREDESIGN-7), so tests
* that assert on a single `transition-gate` testid need to scope to
* one direction at a time.
*/
direction?: "departure" | "arrival";
}): ISimpleFlight {
const base = makeFlight({});
const transition = makeTransitionItem();
// `makeFlight` always returns a Direct flight so `leg` is safe to access
const baseLeg = (base as ReturnType<typeof makeFlight> & { leg: ReturnType<typeof makeLeg> }).leg;
const transitionMap =
opts.direction === "arrival"
? { deboarding: transition }
: { boarding: transition };
const depGate = opts.direction === "arrival" ? undefined : opts.gate;
const arrGate = opts.direction === "arrival" ? opts.gate : undefined;
return {
...base,
leg: {
...baseLeg,
transition: {
boarding: transition,
deboarding: transition,
},
transition: transitionMap,
departure: {
...baseLeg.departure,
gate: opts.gate,
gate: depGate,
dispatch: opts.dispatch,
},
arrival: {
...baseLeg.arrival,
gate: opts.gate,
gate: arrGate,
bagBelt: opts.bagBelt,
},
},
@@ -572,8 +580,8 @@ describe("4.1.13.4 — Expanded row content (TZ §4.1.13.4.3)", () => {
expect(expanded.textContent).toContain("BOARDING-STATUSES.InProgress");
});
it("4.1.13.4-R: deboarding row shown on arrival direction", () => {
const flight = makeFlightWithBoarding({});
it("4.1.13.4-R: deboarding row shown when deboarding transition is active (§4.1.13.4.3)", () => {
const flight = makeFlightWithBoarding({ direction: "arrival" });
render(<FlightCard flight={flight} expandable initialExpanded onViewDetails={() => {}} direction="arrival" />);
const expanded = screen.getByTestId("flight-card-expanded");
expect(expanded.textContent).toContain("DETAILS.DEBOARDING");
@@ -620,7 +628,7 @@ describe("4.1.13.4 — Expanded row content (TZ §4.1.13.4.3)", () => {
});
it("4.1.13.4-R: arrival gate rendered in deboarding row when arrival.gate is set", () => {
const flight = makeFlightWithBoarding({ gate: "C7" });
const flight = makeFlightWithBoarding({ gate: "C7", direction: "arrival" });
render(<FlightCard flight={flight} expandable initialExpanded onViewDetails={() => {}} direction="arrival" />);
expect(screen.getByTestId("transition-gate")).toBeTruthy();
expect(screen.getByTestId("transition-gate").textContent).toContain("C7");
+93 -80
View File
@@ -214,19 +214,21 @@ export const FlightCard: FC<FlightCardProps> = ({
? "SHARED.ACTUAL"
: "SHARED.EXPECTED";
// Arrival pages show the deboarding (Высадка) transition; departure /
// route / flight-number views show boarding (Посадка). Matches Angular.
const isArrival = direction === "arrival";
const transition = isArrival
? arrivalLeg.transition?.deboarding
: departureLeg.transition?.boarding;
const transitionLabelKey = isArrival ? "DETAILS.DEBOARDING" : "DETAILS.BOARDING";
// TIRREDESIGN-7 / TZ §4.1.13.4.1-3: the inline expanded panel renders
// registration (check-in), boarding and deboarding rows side-by-side,
// each visible independently when its `isActual` flag is true. Matches
// Angular's `board-flight-body` template which ngIfs on
// `transition?.X?.isActual` for all three blocks. `direction` no
// longer gates which single block wins — gate/dispatch/bag-belt
// still come from the appropriate end of the leg though.
const registration = departureLeg.transition?.registration;
const boarding = departureLeg.transition?.boarding;
const deboarding = arrivalLeg.transition?.deboarding;
const showRegistration = Boolean(registration?.isActual);
const showBoarding = Boolean(boarding?.isActual);
const showDeboarding = Boolean(deboarding?.isActual);
const anyTransition = showRegistration || showBoarding || showDeboarding;
// TZ §4.1.13.4.3: boarding block shows gate + dispatch (трап/автобус);
// deboarding block shows gate + baggage belt from arrival station.
const boardingGate = isArrival ? arrStation.gate : depStation.gate;
const boardingDispatch = isArrival ? arrStation.dispatch : depStation.dispatch;
const bagBelt = isArrival ? arrStation.bagBelt : undefined;
const BOARDING_STATUS_KEY: Record<string, string> = {
Finished: "BOARDING-STATUSES.Finished",
Expected: "BOARDING-STATUSES.Expected",
@@ -235,6 +237,79 @@ export const FlightCard: FC<FlightCardProps> = ({
Scheduled: "BOARDING-STATUSES.Scheduled",
};
const renderTransitionRow = (
key: "registration" | "boarding" | "deboarding",
item: NonNullable<typeof boarding>,
): JSX.Element => {
const labelKey =
key === "registration"
? "DETAILS.REGISTRATION"
: key === "boarding"
? "DETAILS.BOARDING"
: "DETAILS.DEBOARDING";
// "Время начала" / "Время окончания" apply to all three rows —
// Angular reuses the same SHARED.BOARDING-START/END strings for
// registration rows too.
const startKey = "SHARED.BOARDING-START";
const endKey = "SHARED.BOARDING-END";
// Gate + dispatch surface on boarding (departure side); gate + bag
// belt surface on deboarding (arrival side). Registration has no
// gate-like affordance in the API payload.
const gate = key === "boarding" ? depStation.gate : key === "deboarding" ? arrStation.gate : undefined;
const dispatch = key === "boarding" ? depStation.dispatch : undefined;
const bagBelt = key === "deboarding" ? arrStation.bagBelt : undefined;
return (
<div className="flight-card__detail-row" key={key} data-transition={key}>
<div className="flight-card__detail-label">{t(labelKey)}</div>
<div className="flight-card__detail-group">
<div>
<span className="flight-card__detail-caption">{t("DETAILS.STATUS")}</span>
<span
className={`flight-card__detail-value flight-card__detail-status flight-card__detail-status--${item.status.toLowerCase()}`}
>
<span className="flight-card__status-dot" aria-hidden="true" />
{t(BOARDING_STATUS_KEY[item.status] ?? item.status)}
</span>
</div>
{item.start?.local && (
<div>
<span className="flight-card__detail-caption">{t(startKey)}</span>
<span className="flight-card__detail-value">
{formatLocalTime(item.start.local)}
</span>
</div>
)}
{item.end?.local && (
<div>
<span className="flight-card__detail-caption">{t(endKey)}</span>
<span className="flight-card__detail-value">
{formatLocalTime(item.end.local)}
</span>
</div>
)}
{gate && (
<div data-testid="transition-gate">
<span className="flight-card__detail-caption">{t("DETAILS.GATE")}</span>
<span className="flight-card__detail-value">{gate}</span>
</div>
)}
{dispatch && (
<div data-testid="transition-dispatch">
<span className="flight-card__detail-caption">{t("DETAILS.DISPATCH")}</span>
<span className="flight-card__detail-value">{t(`DISPATCH.${dispatch}`)}</span>
</div>
)}
{bagBelt && (
<div data-testid="transition-bag-belt">
<span className="flight-card__detail-caption">{t("DETAILS.BAG_BELT")}</span>
<span className="flight-card__detail-value">{bagBelt}</span>
</div>
)}
</div>
</div>
);
};
return (
<div
className={`flight-card${rowClickable ? " flight-card--clickable" : ""}${expanded ? " flight-card--expanded" : ""}`}
@@ -485,74 +560,12 @@ export const FlightCard: FC<FlightCardProps> = ({
</div>
</div>
{transition && (
<div className="flight-card__detail-row">
<div className="flight-card__detail-label">
{t(transitionLabelKey)}
</div>
<div className="flight-card__detail-group">
<div>
<span className="flight-card__detail-caption">
{t("DETAILS.STATUS")}
</span>
<span
className={`flight-card__detail-value flight-card__detail-status flight-card__detail-status--${transition.status.toLowerCase()}`}
>
<span className="flight-card__status-dot" aria-hidden="true" />
{t(BOARDING_STATUS_KEY[transition.status] ?? transition.status)}
</span>
</div>
{transition.start?.local && (
<div>
<span className="flight-card__detail-caption">
{t("SHARED.BOARDING-START")}
</span>
<span className="flight-card__detail-value">
{formatLocalTime(transition.start.local)}
</span>
</div>
)}
{transition.end?.local && (
<div>
<span className="flight-card__detail-caption">
{t("SHARED.BOARDING-END")}
</span>
<span className="flight-card__detail-value">
{formatLocalTime(transition.end.local)}
</span>
</div>
)}
{/* TZ §4.1.13.4.3: gate number (выход на посадку) when available */}
{boardingGate && (
<div data-testid="transition-gate">
<span className="flight-card__detail-caption">
{t("DETAILS.GATE")}
</span>
<span className="flight-card__detail-value">{boardingGate}</span>
</div>
)}
{/* TZ §4.1.13.4.3: dispatch type (трап/автобус) when available */}
{boardingDispatch && (
<div data-testid="transition-dispatch">
<span className="flight-card__detail-caption">
{t("DETAILS.DISPATCH")}
</span>
<span className="flight-card__detail-value">
{t(`DISPATCH.${boardingDispatch}`)}
</span>
</div>
)}
{/* TZ §4.1.13.4.3: baggage belt for deboarding (arrival) when available */}
{bagBelt && (
<div data-testid="transition-bag-belt">
<span className="flight-card__detail-caption">
{t("DETAILS.BAG_BELT")}
</span>
<span className="flight-card__detail-value">{bagBelt}</span>
</div>
)}
</div>
</div>
{anyTransition && (
<>
{showRegistration && registration && renderTransitionRow("registration", registration)}
{showBoarding && boarding && renderTransitionRow("boarding", boarding)}
{showDeboarding && deboarding && renderTransitionRow("deboarding", deboarding)}
</>
)}
{onViewDetails && (