feat: structure meal planning taxonomy and favorites

This commit is contained in:
OpenClaw Bot
2026-05-12 17:15:31 +02:00
parent cef366cce4
commit 58a76ee02d
9 changed files with 442 additions and 20 deletions
@@ -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;