feat: structure meal planning taxonomy and favorites
This commit is contained in:
@@ -61,6 +61,24 @@ async function openMealPlanStudioFromAssist(page, diagnostics, prompt = 'Lav en
|
||||
return studioHost;
|
||||
}
|
||||
|
||||
async function readStudioPlan(page) {
|
||||
return page.evaluate(() => JSON.parse(localStorage.getItem('oikos-meal-plan-studio-v1') || 'null'));
|
||||
}
|
||||
|
||||
function assertStructuredStudioPlan(plan) {
|
||||
expect(plan?.slots, 'Studio plan should exist in localStorage for structural assertions').toHaveLength(7);
|
||||
const categories = plan.slots.map((slot) => slot.meal_category || slot.variation?.category || 'other');
|
||||
expect(categories.every((category) => category && category !== 'other'), 'all Studio slots should carry structured categories').toBeTruthy();
|
||||
for (let i = 1; i < categories.length; i += 1) {
|
||||
expect(categories[i], `planner should not repeat category on consecutive days (${i - 1}/${i})`).not.toBe(categories[i - 1]);
|
||||
}
|
||||
const counts = categories.reduce((acc, category) => ({ ...acc, [category]: (acc[category] || 0) + 1 }), {});
|
||||
expect(Math.max(...Object.values(counts)), `planner should not overuse one category: ${JSON.stringify(counts)}`).toBeLessThanOrEqual(2);
|
||||
const leftovers = plan.slots.filter((slot) => (slot.meal_category || slot.variation?.category) === 'leftovers' || slot.leftover_from_meal_id);
|
||||
expect(leftovers.length, 'leftovers should be represented as a dedicated structured option when meal history exists').toBeGreaterThanOrEqual(1);
|
||||
expect(leftovers.every((slot) => slot.leftover_from_meal_id || slot.context?.leftoverSource?.id), 'leftover slots should link to a source dish id').toBeTruthy();
|
||||
}
|
||||
|
||||
async function deleteMeals(page, mealIds) {
|
||||
if (!mealIds.length) return [];
|
||||
return page.evaluate(async (ids) => {
|
||||
@@ -147,6 +165,8 @@ test.describe('Oikos Kitchen + Assist meal-planning flow', () => {
|
||||
|
||||
await openMealPlanStudioFromAssist(page, diagnostics);
|
||||
|
||||
assertStructuredStudioPlan(await readStudioPlan(page));
|
||||
|
||||
expect(diagnostics.pageErrors, 'No browser page errors during Kitchen/Assist flow').toEqual([]);
|
||||
await attachDiagnostics(testInfo, diagnostics);
|
||||
});
|
||||
@@ -184,6 +204,8 @@ test.describe('Oikos Kitchen + Assist meal-planning flow', () => {
|
||||
const editPayload = await editResponse.json();
|
||||
expect(editPayload.source, 'edit feedback should flow through the native meal-planning API').toBe('oikos-native-api');
|
||||
|
||||
assertStructuredStudioPlan(await readStudioPlan(page));
|
||||
|
||||
const actionResponsePromise = page.waitForResponse((res) => res.url().includes('/ai/api/action') && res.request().method() === 'POST');
|
||||
await studioHost.locator('[data-assist-studio-confirm]').click();
|
||||
const actionResponse = await actionResponsePromise;
|
||||
@@ -199,6 +221,48 @@ test.describe('Oikos Kitchen + Assist meal-planning flow', () => {
|
||||
const mealsInOikos = await listMealsById(page, createdMealIds, createdMeals.map((meal) => meal?.date));
|
||||
expect(mealsInOikos, 'created meals should be readable from the native Oikos meals API').toHaveLength(7);
|
||||
expect(mealsInOikos.some((meal) => String(meal.title || '').includes('[E2E cleanup]'))).toBeTruthy();
|
||||
expect(mealsInOikos.every((meal) => meal.meal_category), 'confirmed meals should preserve structured meal categories').toBeTruthy();
|
||||
const leftoverMeal = mealsInOikos.find((meal) => meal.meal_category === 'leftovers');
|
||||
expect(leftoverMeal?.leftover_from_meal_id, 'confirmed leftover meal should link to a source dish').toBeTruthy();
|
||||
|
||||
const favoriteCandidate = mealsInOikos.find((meal) => meal.recipe_id);
|
||||
if (favoriteCandidate) {
|
||||
await page.goto(`/meals?week=${favoriteCandidate.date}`, { waitUntil: 'domcontentloaded' });
|
||||
await dismissOnboardingIfPresent(page);
|
||||
const candidateCard = page.locator('.meal-card').filter({ hasText: favoriteCandidate.title }).first();
|
||||
if (await candidateCard.count()) {
|
||||
await candidateCard.click();
|
||||
} else {
|
||||
await page.locator('.meal-card').first().click();
|
||||
}
|
||||
await expect(page.locator('#modal-recipe-pref-member')).toBeVisible();
|
||||
let modalRecipeId = Number(await page.locator('#modal-recipe-id').inputValue());
|
||||
if (!modalRecipeId) {
|
||||
await page.locator('#modal-recipe-id').selectOption(String(favoriteCandidate.recipe_id));
|
||||
modalRecipeId = Number(await page.locator('#modal-recipe-id').inputValue());
|
||||
}
|
||||
expect(modalRecipeId, 'favorite flow should originate from a meal modal with a saved recipe').toBeTruthy();
|
||||
const memberValue = await page.locator('#modal-recipe-pref-member option').nth(1).getAttribute('value');
|
||||
if (memberValue) {
|
||||
await page.locator('#modal-recipe-pref-member').selectOption(memberValue);
|
||||
const prefResponsePromise = page.waitForResponse((res) => res.url().includes(`/api/v1/meal-planning/recipe-signals/${modalRecipeId}`) && res.request().method() === 'PUT');
|
||||
await page.locator('[data-meal-recipe-pref="favorite"]').click();
|
||||
const prefResponse = await prefResponsePromise;
|
||||
expect(prefResponse.ok(), 'meal modal favorite action should write native recipe signals').toBeTruthy();
|
||||
const members = await page.evaluate(async () => (await (await fetch('/api/v1/family/members', { credentials: 'same-origin' })).json()).data || []);
|
||||
const member = members.find((item) => String(item.id) === String(memberValue));
|
||||
expect(Number(member?.favorite_meal_count || 0), 'family profile card data should reflect favorite meal count').toBeGreaterThanOrEqual(1);
|
||||
diagnostics.favoriteSignalCleanup = await page.evaluate(async ({ recipeId, userId }) => {
|
||||
const csrf = document.cookie.split(';').map((c) => c.trim()).find((c) => c.startsWith('csrf-token='))?.slice('csrf-token='.length) || '';
|
||||
const res = await fetch(`/api/v1/meal-planning/recipe-signals/${recipeId}`, {
|
||||
method: 'PUT', credentials: 'same-origin',
|
||||
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': decodeURIComponent(csrf) },
|
||||
body: JSON.stringify({ user_id: userId, preference: 'neutral', can_cook: false, can_help_cook: false, will_eat_modified: false, adult_only: false }),
|
||||
});
|
||||
return { ok: res.ok, status: res.status };
|
||||
}, { recipeId: modalRecipeId, userId: memberValue });
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
const cleanup = await deleteMeals(page, createdMealIds).catch((err) => [{ error: err.message }]);
|
||||
diagnostics.cleanup = cleanup;
|
||||
|
||||
Reference in New Issue
Block a user