From 057bdcc5d1decf25367df73f94da21385a95cc0e Mon Sep 17 00:00:00 2001 From: OziinG <145884442+OziinG@users.noreply.github.com> Date: Wed, 22 Jul 2026 17:03:36 +0900 Subject: [PATCH] Show server-issued child numbers before route draft save Add Empty keeps the child unsaved, queries the next shop-global route index, and updates the temporary row while preserving existing routes and custom names. Constraint: The childless preview remains #1 until Add Empty requests the real index. Rejected: Saving the child during Add Empty | route creation belongs to the global Save action. Confidence: high Scope-risk: moderate Directive: Do not replace the read-only index lookup with an implicit draft save. Tested: 353 app tests; public URL guard; build; typecheck; git diff check Not-tested: live embedded browser interaction. --- .../features/delivery/route-detail.server.js | 22 +++ .../features/delivery/route-groups.server.js | 26 ++++ .../app/routes/app.routes.$routeId.jsx | 138 ++++++++++++------ .../tests/route-groups-helper.test.mjs | 15 ++ apps/shopify-app/tests/routes-page.test.mjs | 64 ++++++-- 5 files changed, 210 insertions(+), 55 deletions(-) diff --git a/apps/shopify-app/app/features/delivery/route-detail.server.js b/apps/shopify-app/app/features/delivery/route-detail.server.js index e656cf5..430794c 100644 --- a/apps/shopify-app/app/features/delivery/route-detail.server.js +++ b/apps/shopify-app/app/features/delivery/route-detail.server.js @@ -5,6 +5,7 @@ import { fetchDeliveryOrders, syncDeliveryOrders } from "./orders.server"; import { deleteDeliveryRouteGroup, fetchDeliveryRouteGroupDetail, + fetchNextDeliveryRouteGroupRouteIdx, previewDeliveryRouteGroupOptimization, saveDeliveryRouteGroupDraft, } from "./route-groups.server"; @@ -544,6 +545,27 @@ export const routeDetailAction = async ({ params, request }) => { return result; } + if (intent === "queryNextRouteIdx") { + const tempId = textOrUndefined(formData.get("tempId")) ?? null; + logRouteDetailPerformance("routes.detail.action.queryNextRouteIdx.request", { + routeGroupId, + routeId, + tempId, + }); + const result = await fetchNextDeliveryRouteGroupRouteIdx( + request, + routeGroupId, + { sessionToken: shopifySessionToken }, + ); + logRouteDetailPerformance("routes.detail.action.queryNextRouteIdx.done", { + errorCount: result.errors.length, + nextRouteIdx: result.nextRouteIdx, + routeGroupId, + routeId, + }); + return { ...result, intent: "queryNextRouteIdx", tempId }; + } + return { routePlan: null, stops: [], diff --git a/apps/shopify-app/app/features/delivery/route-groups.server.js b/apps/shopify-app/app/features/delivery/route-groups.server.js index 6717c09..b90fd91 100644 --- a/apps/shopify-app/app/features/delivery/route-groups.server.js +++ b/apps/shopify-app/app/features/delivery/route-groups.server.js @@ -97,6 +97,27 @@ export async function saveDeliveryRouteGroupDraft(request, routeGroupId, payload return mutateRouteGroup(request, routeGroupId, "/draft", payload, options, "저장할 route group ID가 없습니다."); } +export async function fetchNextDeliveryRouteGroupRouteIdx(request, routeGroupId, options = {}) { + const safeRouteGroupId = encodeURIComponent(routeGroupId ?? ""); + if (!safeRouteGroupId) return { nextRouteIdx: null, errors: missingRouteGroupResult("조회할 route group ID가 없습니다.").errors }; + + const result = await deliveryApiRequest(request, `/admin/route-groups/${safeRouteGroupId}/next-route-idx`, { + cacheKey: `next-route-idx:${Date.now()}:${Math.random()}`, + fetch: options.fetch, + method: "GET", + sessionToken: options.sessionToken, + }); + + return { + nextRouteIdx: numberOrNull( + result.data?.nextRouteIdx + ?? result.data?.nextRouteIndex + ?? result.data?.routeIdx, + ), + errors: result.errors, + }; +} + export async function deleteDeliveryRouteGroup(request, routeGroupId, options = {}) { const safeRouteGroupId = encodeURIComponent(routeGroupId ?? ""); if (!safeRouteGroupId) return missingRouteGroupResult("삭제할 route group ID가 없습니다."); @@ -339,6 +360,11 @@ function uniqueTexts(values) { return Array.from(new Set(values.map(textOrUndefined).filter(Boolean))); } +function numberOrNull(value) { + const number = Number(value); + return Number.isFinite(number) ? number : null; +} + export async function generateDeliveryRouteGroupChildRoutes(request, routeGroupId, payload = {}, options = {}) { const safeRouteGroupId = encodeURIComponent(routeGroupId ?? ""); if (!safeRouteGroupId) return missingRouteGroupResult("생성할 route group ID가 없습니다."); diff --git a/apps/shopify-app/app/routes/app.routes.$routeId.jsx b/apps/shopify-app/app/routes/app.routes.$routeId.jsx index 8f5153d..57ab256 100644 --- a/apps/shopify-app/app/routes/app.routes.$routeId.jsx +++ b/apps/shopify-app/app/routes/app.routes.$routeId.jsx @@ -2084,6 +2084,7 @@ function buildUnsplitRouteGroupRow(routeGroup, routeStops = []) { driveTimeLabel: ROUTE_EMPTY_LABEL, id: `routeGroup:${routeGroup.id}:routeIdx:1`, isCurrent: false, + isGeneratedTitle: true, isPreviewOnly: true, optimized: null, orderIds: routeStops.map((stop) => stop.orderId).filter(Boolean), @@ -2179,6 +2180,7 @@ function applyRouteRowDraftState(routeRows, routeLineEdits, routePreviewByKey) { : routeRow.scheduledStartTimeZone, startDateTime: routeLineEdit.startDateTime ?? routeRow.startDateTime, startTimeLabel: routeLineEdit.startTimeLabel ?? routeRow.startTimeLabel, + isGeneratedTitle: Object.hasOwn(routeLineEdit, "title") ? false : routeRow.isGeneratedTitle === true, title: routeLineEdit.title ?? routeRow.title, }; }); @@ -2230,6 +2232,7 @@ function getNextChildRouteDraft(routeRows) { const routeNumber = (maxRouteIdx || routeRows.length) + 1; return { color: getUnusedRouteColor(null, usedColors, routeNumber - 1), + isGeneratedTitle: true, label: `#${routeNumber}`, routeIdx: routeNumber, routeIndex: routeNumber, @@ -2341,6 +2344,10 @@ function shouldIncludeRouteDraftRow(routeRow, includeEmptyTempRoutes) { return !(routeRow.tempId && !routeRow.routePlanId && routeRow.stops.length === 0); } +function getRouteDraftLabel(routeRow) { + return routeRow.isGeneratedTitle ? null : routeRow.title; +} + function buildRouteDraftPayload(routeRows, { deletedRoutePlanIds = [], expectedUpdatedAt, @@ -2364,7 +2371,7 @@ function buildRouteDraftPayload(routeRows, { driverId: routeRow.driverId ?? null, ...(routeRow.expectedChildUpdatedAt ? { expectedChildUpdatedAt: routeRow.expectedChildUpdatedAt } : {}), ...(routeRow.expectedRoutePlanUpdatedAt ? { expectedRoutePlanUpdatedAt: routeRow.expectedRoutePlanUpdatedAt } : {}), - label: routeRow.title, + label: getRouteDraftLabel(routeRow), ...(optimized === undefined ? {} : { optimized }), orderIds: routeRow.stops.map((stop) => stop.orderId).filter(Boolean), routeKey: getRouteRowDraftKey(routeRow), @@ -2534,7 +2541,7 @@ export default function RouteDetailPage() { const routeGroupActionBusy = routeActionFetcher.state !== "idle"; const routeGroupActionIntent = routeActionFetcher.formData?.get("_intent"); const reOptimizeRouteGroupBusy = routeGroupActionBusy && routeGroupActionIntent === "previewRouteOptimization"; - const addEmptyRouteBranchBusy = false; + const addEmptyRouteBranchBusy = routeGroupActionBusy && routeGroupActionIntent === "queryNextRouteIdx"; const saveRouteDraftBusy = routeGroupActionBusy && routeGroupActionIntent === "saveRouteDraft"; const deleteRouteBusy = routeGroupActionBusy && routeGroupActionIntent === "deleteRoute"; const refreshRouteOrdersBusy = routeGroupActionBusy && routeGroupActionIntent === "refreshRouteOrders"; @@ -2711,6 +2718,11 @@ export default function RouteDetailPage() { || Object.keys(routeLineEdits).length > 0 || Object.keys(routePreviewByKey).length > 0 || removedOrderIds.length > 0; + const hasIncompatibleAddEmptyDraft = Object.keys(routeTimelineOrderByRouteId).length > 0 + || deletedRoutePlanIds.length > 0 + || Object.keys(routeLineEdits).length > 0 + || Object.keys(routePreviewByKey).length > 0 + || removedOrderIds.length > 0; const canSaveRouteDraft = hasEditableRouteRows && hasRouteAllocationDraft && !routeGroupActionBusy @@ -3628,58 +3640,59 @@ export default function RouteDetailPage() { }, []); const handleAddEmptyRoute = () => { - const previewRouteRow = contextRouteRows.find((routeRow) => routeRow.isPreviewOnly); - if (previewRouteRow) { - const tempId = `temp:${Date.now()}-${Math.random().toString(16).slice(2)}`; - setClientRouteRows((rows) => [ - ...rows, - { - ...previewRouteRow, - id: tempId, - isMaterializedDraft: true, - isPreviewOnly: false, - routeKey: tempId, - routePlanId: null, - routeIdx: previewRouteRow.routeIdx, - stops: previewRouteRow.stops, - tempId, - }, - ]); + if (routeGroupActionBusy) return; + if (hasIncompatibleAddEmptyDraft) { + setRouteGroupClientError("저장하지 않은 Route 변경을 먼저 Save 또는 Revert 해주세요."); return; } - const draft = getNextChildRouteDraft(contextRouteRows); const tempId = `temp:${Date.now()}-${Math.random().toString(16).slice(2)}`; - setClientRouteRows((rows) => [ - ...rows, - { - attemptedCount: 0, - color: draft.color, - createdLabel: ROUTE_EMPTY_LABEL, - startDateTime: "", - deliveredCount: 0, - driverId: null, - driverLabel: "Unassigned", - driveTimeLabel: ROUTE_EMPTY_LABEL, + const previewRouteRow = contextRouteRows.find((routeRow) => routeRow.isPreviewOnly); + const routeRow = previewRouteRow + ? { + ...previewRouteRow, id: tempId, - isCurrent: false, - orderIds: [], + isGeneratedTitle: previewRouteRow.isGeneratedTitle === true, + isMaterializedDraft: true, + isPreviewOnly: false, routeKey: tempId, - routeIdx: draft.routeIdx, - routeIndex: draft.routeIndex, routePlanId: null, - scheduledStartAt: null, - scheduledStartTimeZone: null, - startTimeLabel: ROUTE_EMPTY_LABEL, - stops: [], - stopsCount: 0, tempId, - title: draft.label, - totalDistanceLabel: ROUTE_EMPTY_LABEL, - totalItems: 0, - totalWeightLabel: ROUTE_EMPTY_LABEL, - }, - ]); + } + : (() => { + const draft = getNextChildRouteDraft(contextRouteRows); + return { + attemptedCount: 0, + color: draft.color, + createdLabel: ROUTE_EMPTY_LABEL, + startDateTime: "", + deliveredCount: 0, + driverId: null, + driverLabel: "Unassigned", + driveTimeLabel: ROUTE_EMPTY_LABEL, + id: tempId, + isCurrent: false, + isGeneratedTitle: draft.isGeneratedTitle === true, + isMaterializedDraft: false, + orderIds: [], + routeKey: tempId, + routeIdx: draft.routeIdx, + routeIndex: draft.routeIndex, + routePlanId: null, + scheduledStartAt: null, + scheduledStartTimeZone: null, + startTimeLabel: ROUTE_EMPTY_LABEL, + stops: [], + stopsCount: 0, + tempId, + title: draft.label, + totalDistanceLabel: ROUTE_EMPTY_LABEL, + totalItems: 0, + totalWeightLabel: ROUTE_EMPTY_LABEL, + }; + })(); + setClientRouteRows((rows) => [...rows, routeRow]); + submitRouteGroupAction("queryNextRouteIdx", { tempId }); }; const handlePreviewRouteOptimization = () => { @@ -3850,6 +3863,39 @@ export default function RouteDetailPage() { shopify.toast.show(`${updatedOrders} orders updated across ${refreshedRoutes} routes${skippedMessage}`); }, [revalidator, routeActionFetcher.data, routeActionFetcher.state, shopify]); + useEffect(() => { + if (routeActionFetcher.state !== "idle" || routeActionFetcher.data === undefined) return; + if (lastRouteActionIntentRef.current !== "queryNextRouteIdx") return; + lastRouteActionIntentRef.current = null; + + const errors = routeActionFetcher.data?.errors ?? []; + const tempId = routeActionFetcher.data?.tempId; + const nextRouteIdx = numberOrUndefined(routeActionFetcher.data?.nextRouteIdx); + if (errors.length > 0 || !tempId || nextRouteIdx === undefined) { + setClientRouteRows((rows) => tempId ? rows.filter((routeRow) => routeRow.tempId !== tempId) : rows); + setRouteGroupClientError(errors[0]?.message ?? "다음 route 번호를 조회하지 못했습니다."); + return; + } + + setClientRouteRows((rows) => rows.map((routeRow) => { + if (routeRow.tempId !== tempId) return routeRow; + const routeIdx = Math.max( + nextRouteIdx, + numberOrUndefined(routeRow.routeIdx) ?? numberOrUndefined(routeRow.routeIndex) ?? nextRouteIdx, + ); + const routeLineEdit = routeLineEdits[routeRow.id] ?? {}; + const isGeneratedTitle = Object.hasOwn(routeLineEdit, "title") ? false : routeRow.isGeneratedTitle === true; + const title = isGeneratedTitle ? `#${routeIdx}` : routeRow.title; + return { + ...routeRow, + isGeneratedTitle, + routeIdx, + routeIndex: routeIdx, + title, + }; + })); + }, [routeActionFetcher.data, routeActionFetcher.state, routeLineEdits]); + useEffect(() => { if (routeActionFetcher.state !== "idle" || routeActionFetcher.data === undefined) return; if (lastRouteActionIntentRef.current !== "saveRouteDraft") return; diff --git a/apps/shopify-app/tests/route-groups-helper.test.mjs b/apps/shopify-app/tests/route-groups-helper.test.mjs index a0dd92d..b17f195 100644 --- a/apps/shopify-app/tests/route-groups-helper.test.mjs +++ b/apps/shopify-app/tests/route-groups-helper.test.mjs @@ -10,6 +10,7 @@ import { deleteDeliveryRouteGroupChildRoute, DELIVERY_ROUTE_GROUP_ID_MISSING_ERROR_CODE, fetchDeliveryRouteGroups, + fetchNextDeliveryRouteGroupRouteIdx, generateDeliveryRouteGroupChildRoutes, previewDeliveryRouteGroupOptimization, saveDeliveryRouteGroupDraft, @@ -158,6 +159,20 @@ test("route group helper saves a batched draft allocation", async () => { assert.deepEqual(JSON.parse(fakeFetch.calls[0].init.body), payload); }); +test("route group helper queries the next child route index without saving", async () => { + const fakeFetch = makeFetch({ data: { nextRouteIdx: 7 }, error: null }); + + const result = await fetchNextDeliveryRouteGroupRouteIdx(makeRequest(), "group/1", { + fetch: fakeFetch, + sessionToken: "session-token", + }); + + assert.deepEqual(result, { nextRouteIdx: 7, errors: [] }); + assert.equal(fakeFetch.calls[0].url, "https://delivery.test/admin/route-groups/group%2F1/next-route-idx"); + assert.equal(fakeFetch.calls[0].init.method, "GET"); + assert.equal(fakeFetch.calls[0].init.body, undefined); +}); + test("route group child delete draft merges deleted child orders into the first route", () => { const group = { id: "group/1", diff --git a/apps/shopify-app/tests/routes-page.test.mjs b/apps/shopify-app/tests/routes-page.test.mjs index 53657d4..3c32b32 100644 --- a/apps/shopify-app/tests/routes-page.test.mjs +++ b/apps/shopify-app/tests/routes-page.test.mjs @@ -385,6 +385,16 @@ test("Route detail wires route group action buttons through App Bridge", () => { assert.doesNotMatch(routeDetailSource, /return redirect\(`\/app\/routes/); assert.match(routeDetailServerSource, /intent === "previewRouteOptimization"/); assert.match(routeDetailServerSource, /intent === "saveRouteDraft"/); + assert.match(routeDetailServerSource, /intent === "queryNextRouteIdx"/); + assert.match(routeDetailServerSource, /fetchNextDeliveryRouteGroupRouteIdx/); + { + const start = routeDetailServerSource.indexOf('if (intent === "queryNextRouteIdx")'); + const end = routeDetailServerSource.indexOf("return {", start); + const queryNextRouteIdxAction = routeDetailServerSource.slice(start, end); + assert.match(queryNextRouteIdxAction, /const tempId = textOrUndefined\(formData\.get\("tempId"\)\) \?\? null/); + assert.doesNotMatch(queryNextRouteIdxAction, /readRouteDraftPayload/); + assert.doesNotMatch(queryNextRouteIdxAction, /saveDeliveryRouteGroupDraft/); + } assert.doesNotMatch(routeDetailSource, /intent === "addEmptyRouteBranch"/); assert.doesNotMatch(routeDetailSource, /intent === "assignPolygonToRoute"/); assert.match(routeDetailSource, /const shopify = useAppBridge\(\)/); @@ -397,13 +407,15 @@ test("Route detail wires route group action buttons through App Bridge", () => { assert.match(routeDetailSource, /routeActionFetcher\.submit\(formData, \{ method: "post" \}\)/); assert.match(routeDetailSource, /const routeGroupActionIntent = routeActionFetcher\.formData\?\.get\("_intent"\)/); assert.match(routeDetailSource, /const reOptimizeRouteGroupBusy = routeGroupActionBusy && routeGroupActionIntent === "previewRouteOptimization"/); - assert.match(routeDetailSource, /const addEmptyRouteBranchBusy = false/); + assert.match(routeDetailSource, /const addEmptyRouteBranchBusy = routeGroupActionBusy && routeGroupActionIntent === "queryNextRouteIdx"/); assert.match(routeDetailSource, /\{reOptimizeRouteGroupBusy \? "Working…" : "Re-optimize"\}/); assert.match(routeDetailSource, /\{addEmptyRouteBranchBusy \? "Working…" : "Add Empty Route"\}/); assert.match(routeDetailSource, /submitRouteGroupAction\("previewRouteOptimization", \{[\s\S]*includeExistingOptimized: true/); assert.match(routeDetailSource, /submitRouteGroupAction\("saveRouteDraft", \{[\s\S]*includeExistingOptimized: false/); assert.match(routeDetailSource, /const handleAddEmptyRoute = \(\) => \{/); - assert.match(routeDetailSource, /setClientRouteRows\(\(rows\) => \[/); + assert.match(routeDetailSource, /if \(routeGroupActionBusy\) return/); + assert.match(routeDetailSource, /submitRouteGroupAction\("queryNextRouteIdx", \{ tempId \}\)/); + assert.doesNotMatch(routeDetailSource, /submitRouteGroupAction\("addEmptyRoute"/); assert.match(routeDetailSource, /const polygonCandidateOrderIds = polygonCandidateStops\.map\(\(stop\) => stop\.orderId\)/); assert.doesNotMatch(routeDetailSource, /routeTimelineStopSelectedStyle/); assert.match(routeDetailSource, /setRouteTimelineOrderByRouteId\(\(currentOrderByRouteId\) =>/); @@ -990,11 +1002,29 @@ test("Route group detail keeps an unsplit group visible as route #1", () => { assert.match(routeDetailSource, /return routeGroupChildRows\.length > 0 \? routeGroupChildRows : \[buildUnsplitRouteGroupRow\(routeGroup, routeStops\)\]\.filter\(Boolean\)/); }); -test("Route group detail materializes the preview only after Add Empty Route", () => { - assert.match(routeDetailSource, /const previewRouteRow = contextRouteRows\.find\(\(routeRow\) => routeRow\.isPreviewOnly\)/); - assert.match(routeDetailSource, /stops: previewRouteRow\.stops/); - assert.match(routeDetailSource, /routeIdx: previewRouteRow\.routeIdx/); - assert.match(routeDetailSource, /isMaterializedDraft: true/); +test("Route group detail Add Empty Route queries server numbering without saving", () => { + const start = routeDetailSource.indexOf("const handleAddEmptyRoute = () => {"); + const end = routeDetailSource.indexOf("const handlePreviewRouteOptimization = () => {", start); + const addEmptyHandler = routeDetailSource.slice(start, end); + + assert.match(addEmptyHandler, /if \(routeGroupActionBusy\) return/); + assert.match(addEmptyHandler, /if \(hasIncompatibleAddEmptyDraft\) \{/); + assert.match(addEmptyHandler, /setRouteGroupClientError\("저장하지 않은 Route 변경을 먼저 Save 또는 Revert 해주세요\."\)/); + assert.match(routeDetailSource, /const hasIncompatibleAddEmptyDraft = Object\.keys\(routeTimelineOrderByRouteId\)\.length > 0[\s\S]*\|\| removedOrderIds\.length > 0/); + assert.doesNotMatch(routeDetailSource, /const hasIncompatibleAddEmptyDraft =[\s\S]*clientRouteRows\.length > 0/); + assert.match(addEmptyHandler, /const previewRouteRow = contextRouteRows\.find\(\(routeRow\) => routeRow\.isPreviewOnly\)/); + assert.match(addEmptyHandler, /\.\.\.previewRouteRow/); + assert.match(addEmptyHandler, /isPreviewOnly: false/); + assert.match(addEmptyHandler, /isMaterializedDraft: true/); + assert.match(addEmptyHandler, /isMaterializedDraft: false/); + assert.match(addEmptyHandler, /title: draft\.label/); + assert.match(addEmptyHandler, /isGeneratedTitle: draft\.isGeneratedTitle === true/); + assert.match(routeDetailSource, /const maxRouteIdx = routeRows\.reduce/); + assert.match(addEmptyHandler, /setClientRouteRows\(\(rows\) => \[\.\.\.rows, routeRow\]\)/); + assert.match(addEmptyHandler, /submitRouteGroupAction\("queryNextRouteIdx", \{ tempId \}\)/); + assert.doesNotMatch(addEmptyHandler, /buildRouteDraftPayload/); + assert.doesNotMatch(addEmptyHandler, /saveRouteDraft/); + assert.match(routeDetailSource, /const addEmptyRouteBranchBusy = routeGroupActionBusy && routeGroupActionIntent === "queryNextRouteIdx"/); assert.match(routeDetailSource, /const hasMaterializedClientRoute = clientRouteRows\.some\(\(routeRow\) => routeRow\.isMaterializedDraft\)/); assert.match(routeDetailSource, /hasMaterializedClientRoute \? \[\] : routeGroupChildRows/); assert.match(routeDetailSource, /const canSaveRoutePolygon = hasEditableRouteRows && polygonCandidateOrderIds\.length > 0/); @@ -1003,6 +1033,22 @@ test("Route group detail materializes the preview only after Add Empty Route", ( assert.match(routeDetailSource, /disabled=\{!routeRow\.routePlanId\}/); }); +test("Route group detail applies next route idx lookup to generated temp names only", () => { + const start = routeDetailSource.indexOf('if (lastRouteActionIntentRef.current !== "queryNextRouteIdx") return;'); + const end = routeDetailSource.indexOf("useEffect(() => {", start + 1); + const nextIdxEffect = routeDetailSource.slice(start, end); + + assert.match(nextIdxEffect, /const nextRouteIdx = numberOrUndefined\(routeActionFetcher\.data\?\.nextRouteIdx\)/); + assert.match(nextIdxEffect, /setClientRouteRows\(\(rows\) => tempId \? rows\.filter\(\(routeRow\) => routeRow\.tempId !== tempId\) : rows\)/); + assert.match(nextIdxEffect, /const routeIdx = Math\.max\(/); + assert.match(nextIdxEffect, /nextRouteIdx,[\s\S]*numberOrUndefined\(routeRow\.routeIdx\) \?\? numberOrUndefined\(routeRow\.routeIndex\) \?\? nextRouteIdx/); + assert.match(nextIdxEffect, /const isGeneratedTitle = Object\.hasOwn\(routeLineEdit, "title"\) \? false : routeRow\.isGeneratedTitle === true/); + assert.match(nextIdxEffect, /const title = isGeneratedTitle \? `#\$\{routeIdx\}` : routeRow\.title/); + assert.match(nextIdxEffect, /routeIdx,/); + assert.match(nextIdxEffect, /routeIndex: routeIdx/); + assert.doesNotMatch(nextIdxEffect, /revalidator\.revalidate\(\)/); +}); + test("Route detail draft payload is child-only and treats routeIdx as server assertion", () => { const start = routeDetailSource.indexOf("function buildRouteDraftPayload("); const end = routeDetailSource.indexOf("function renderRouteHeaderMetric", start); @@ -1014,6 +1060,8 @@ test("Route detail draft payload is child-only and treats routeIdx as server ass assert.match(payloadBuilder, /branchId: null/); assert.match(payloadBuilder, /driverId: routeRow\.driverId \?\? null/); assert.match(payloadBuilder, /scheduledStartTimeZone: routeRow\.scheduledStartTimeZone \?\? null/); + assert.match(payloadBuilder, /label: getRouteDraftLabel\(routeRow\)/); + assert.match(routeDetailSource, /function getRouteDraftLabel\(routeRow\) \{[\s\S]*return routeRow\.isGeneratedTitle \? null : routeRow\.title/); assert.match(payloadBuilder, /deletedRoutePlanIds/); assert.match(payloadBuilder, /removedOrderIds/); assert.doesNotMatch(routeDetailSource, /routeKey: "root"/); @@ -1042,8 +1090,6 @@ test("Route detail renders route lines and a stop timeline below the map", () => assert.doesNotMatch(routeDetailSource, /routeBranchRows/); assert.match(routeDetailSource, /formatRouteDurationSeconds\(optimized\?\.metrics\?\.durationSeconds\)/); assert.match(routeDetailSource, /formatRouteDistanceMeters\(optimized\?\.metrics\?\.distanceMeters\)/); - assert.match(routeDetailSource, /const maxRouteIdx = routeRows\.reduce/); - assert.match(routeDetailSource, /const draft = getNextChildRouteDraft\(contextRouteRows\)/); assert.match(routeDetailSource, /routeIdx: draft\.routeIdx/); assert.match(routeDetailSource, /routeIndex: draft\.routeIndex/); assert.match(routeHelpersSource, /getDefaultRouteGroupChildName\(index, child\)/);