u
This commit is contained in:
@@ -142,6 +142,37 @@ const id = await store.category.ensureByPath({ googleAuthId: 42, parentId: 5, pa
|
|||||||
// => 12
|
// => 12
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## `category.generateImage`
|
||||||
|
|
||||||
|
*Source: `src/store/category/generateImage.js`*
|
||||||
|
|
||||||
|
Generates a mosaic image directly from the category's and its subcategories' articles (grouped by subcategory) and sets it as the new main picture for the category.
|
||||||
|
|
||||||
|
**Parameters**
|
||||||
|
|
||||||
|
| Name | Type | Description |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `googleAuthId` | `number` | The id of the Google auth user. |
|
||||||
|
| `inputOrCategoryId` | `number|object` | The id of the category or an options object. |
|
||||||
|
| `[maybeOptsOrSize={}]` | `object|number` | Options object or numeric size. |
|
||||||
|
| `[maybeOptsOrSize.size]` | `number` | Grid size (2 for 2x2, 3 for 3x3, 4 for 4x4). Defaults to 3 (falls back to 2 if < 7 images). |
|
||||||
|
| `[maybeTx]` | `object` | Optional transaction to run within. |
|
||||||
|
|
||||||
|
**Returns**
|
||||||
|
|
||||||
|
`Promise<{id: number, fullHash: string, thumbW: number, thumbH: number, thumbHash: string, smallThumbW: number, smallThumbH: number, size: number, grid: string, articleCount: number}>` — The new main image info.
|
||||||
|
|
||||||
|
**Throws**
|
||||||
|
|
||||||
|
`Error` — If the category is not found, not authorized, size is invalid, or no images are found.
|
||||||
|
|
||||||
|
**Example**
|
||||||
|
|
||||||
|
```js
|
||||||
|
const result = await store.category.generateImage(42, 5, { size: 3 });
|
||||||
|
// => { id: 5, fullHash, thumbW, thumbH, thumbHash, smallThumbW, smallThumbH, size: 3, grid: '3x3', articleCount: 12 }
|
||||||
|
```
|
||||||
|
|
||||||
## `category.getCategoryArticles`
|
## `category.getCategoryArticles`
|
||||||
|
|
||||||
*Source: `src/store/category/getCategoryArticles.js`*
|
*Source: `src/store/category/getCategoryArticles.js`*
|
||||||
|
|||||||
1
API.md
1
API.md
@@ -99,6 +99,7 @@ The store is exposed as a `LibDashStore` instance (see `src/index.js`). Each met
|
|||||||
- `delete(input: {googleAuthId: number, id: number, recursive?: boolean}, tx?: object): Promise<void>`
|
- `delete(input: {googleAuthId: number, id: number, recursive?: boolean}, tx?: object): Promise<void>`
|
||||||
- `deleteImage(googleAuthId: number, categoryId: number, tx?: object): Promise<{id: number, thumbW: null, thumbH: null, thumbHash: null}>`
|
- `deleteImage(googleAuthId: number, categoryId: number, tx?: object): Promise<{id: number, thumbW: null, thumbH: null, thumbHash: null}>`
|
||||||
- `ensureByPath(input: {googleAuthId: number, parentId: number, path: string}, tx?: object): Promise<number>`
|
- `ensureByPath(input: {googleAuthId: number, parentId: number, path: string}, tx?: object): Promise<number>`
|
||||||
|
- `generateImage(googleAuthId: number, inputOrCategoryId: number|object, maybeOptsOrSize?: {size?: number}, maybeTx?: object): Promise<{id: number, fullHash: string, thumbW: number, thumbH: number, thumbHash: string, smallThumbW: number, smallThumbH: number, size: number, grid: string, articleCount: number}>`
|
||||||
- `getCategoryArticles(googleAuthId: number, categoryId: number, tx?: object): Promise<Array<{id: number, sku: string, name: string, slug: string, net: number|null, taxRate: number|null, optionsCount: number, articleListCount: number, childCount: number, parentArticleId: number|null, parentSku: string|null, fullHash: string|null, thumbW: number|null, thumbH: number|null, thumbHash: string|null, smallThumbW: number|null, smallThumbH: number|null, smallThumbBinary: string|null, brand: {id: number|null, name: string, fullHash: string|null, thumbW: number|null, thumbH: number|null, thumbHash: string|null}|null, articlewarehousesummary: Array<{id: number, articleId: number, warehouseId: number, warehouseName: string, stock: number}>, warehouses: Array<{id: number, name: string, stock: number}>}>>`
|
- `getCategoryArticles(googleAuthId: number, categoryId: number, tx?: object): Promise<Array<{id: number, sku: string, name: string, slug: string, net: number|null, taxRate: number|null, optionsCount: number, articleListCount: number, childCount: number, parentArticleId: number|null, parentSku: string|null, fullHash: string|null, thumbW: number|null, thumbH: number|null, thumbHash: string|null, smallThumbW: number|null, smallThumbH: number|null, smallThumbBinary: string|null, brand: {id: number|null, name: string, fullHash: string|null, thumbW: number|null, thumbH: number|null, thumbHash: string|null}|null, articlewarehousesummary: Array<{id: number, articleId: number, warehouseId: number, warehouseName: string, stock: number}>, warehouses: Array<{id: number, name: string, stock: number}>}>>`
|
||||||
- `getTreeForCompany(googleAuthId: number, tx?: object): Promise<{languageId: number, tree: Array<{id: number, parentId: number|null, sort: number, name: string|null, slug: string|null, fullHash: string|null, thumbW: number|null, thumbH: number|null, thumbHash: string|null, articleCount: number, posterminalIds: number[]}>, articleCount: number}>`
|
- `getTreeForCompany(googleAuthId: number, tx?: object): Promise<{languageId: number, tree: Array<{id: number, parentId: number|null, sort: number, name: string|null, slug: string|null, fullHash: string|null, thumbW: number|null, thumbH: number|null, thumbHash: string|null, articleCount: number, posterminalIds: number[]}>, articleCount: number}>`
|
||||||
- `move(input: {googleAuthId: number, id: number, parentId: number}, tx?: object): Promise<{id: number, parentId: number, sort: number}>`
|
- `move(input: {googleAuthId: number, id: number, parentId: number}, tx?: object): Promise<{id: number, parentId: number, sort: number}>`
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import categoryOrder from './store/category/order.js';
|
|||||||
import categoryDelete from './store/category/delete.js';
|
import categoryDelete from './store/category/delete.js';
|
||||||
import categoryAddImage from './store/category/addImage.js';
|
import categoryAddImage from './store/category/addImage.js';
|
||||||
import categoryDeleteImage from './store/category/deleteImage.js';
|
import categoryDeleteImage from './store/category/deleteImage.js';
|
||||||
|
import categoryGenerateImage from './store/category/generateImage.js';
|
||||||
import articleDelete from './store/article/delete.js';
|
import articleDelete from './store/article/delete.js';
|
||||||
import articleDeleteImage from './store/article/deleteImage.js';
|
import articleDeleteImage from './store/article/deleteImage.js';
|
||||||
import articleOrderImages from './store/article/orderImages.js';
|
import articleOrderImages from './store/article/orderImages.js';
|
||||||
@@ -321,6 +322,7 @@ export default class LibDashStore {
|
|||||||
delete: categoryDelete(provider),
|
delete: categoryDelete(provider),
|
||||||
addImage: categoryAddImage(provider),
|
addImage: categoryAddImage(provider),
|
||||||
deleteImage: categoryDeleteImage(provider),
|
deleteImage: categoryDeleteImage(provider),
|
||||||
|
generateImage: categoryGenerateImage(provider),
|
||||||
};
|
};
|
||||||
|
|
||||||
this.article = {
|
this.article = {
|
||||||
|
|||||||
@@ -178,3 +178,31 @@ export const DELETE = `
|
|||||||
DELETE FROM category
|
DELETE FROM category
|
||||||
WHERE id = @id
|
WHERE id = @id
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
export const LIST_CHILD_CATEGORIES = `
|
||||||
|
SELECT id
|
||||||
|
FROM category
|
||||||
|
WHERE parent_id = @parentId
|
||||||
|
ORDER BY sort ASC, id ASC
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const GET_CATEGORY_ARTICLE_PICS = `
|
||||||
|
SELECT
|
||||||
|
a.id AS articleId,
|
||||||
|
p.id AS picId,
|
||||||
|
p.hash,
|
||||||
|
p.fullbinary AS buffer
|
||||||
|
FROM categoryarticle ca
|
||||||
|
INNER JOIN article a ON a.id = ca.article_id
|
||||||
|
OUTER APPLY (
|
||||||
|
SELECT TOP 1 ap.pic_id
|
||||||
|
FROM articlepic ap
|
||||||
|
WHERE ap.article_id = a.id
|
||||||
|
ORDER BY ap.sort ASC, ap.id ASC
|
||||||
|
) first_pic
|
||||||
|
INNER JOIN pic p ON p.id = first_pic.pic_id
|
||||||
|
WHERE ca.category_id = @categoryId
|
||||||
|
AND a.parentarticle_id IS NULL
|
||||||
|
ORDER BY a.id ASC
|
||||||
|
`;
|
||||||
|
|
||||||
|
|||||||
@@ -7,9 +7,29 @@ export const {
|
|||||||
CREATE, DELETE, GET_BY_PARENT_AND_NAME,
|
CREATE, DELETE, GET_BY_PARENT_AND_NAME,
|
||||||
GET_CATEGORY_ARTICLE_WAREHOUSE_SUMMARIES,
|
GET_CATEGORY_ARTICLE_WAREHOUSE_SUMMARIES,
|
||||||
GET_NEXT_SIBLING_SORT, LIST_CHILD_IDS,
|
GET_NEXT_SIBLING_SORT, LIST_CHILD_IDS,
|
||||||
|
LIST_CHILD_CATEGORIES,
|
||||||
MOVE, UPDATE_SORT,
|
MOVE, UPDATE_SORT,
|
||||||
} = queries;
|
} = queries;
|
||||||
|
|
||||||
|
export const GET_CATEGORY_ARTICLE_PICS = `
|
||||||
|
SELECT
|
||||||
|
a.id AS articleId,
|
||||||
|
p.id AS picId,
|
||||||
|
p.hash,
|
||||||
|
NULL AS buffer
|
||||||
|
FROM categoryarticle ca
|
||||||
|
INNER JOIN article a ON a.id = ca.article_id
|
||||||
|
INNER JOIN pic p ON p.id = (
|
||||||
|
SELECT ap.pic_id FROM articlepic ap
|
||||||
|
WHERE ap.article_id = a.id
|
||||||
|
ORDER BY ap.sort ASC, ap.id ASC
|
||||||
|
LIMIT 1
|
||||||
|
)
|
||||||
|
WHERE ca.category_id = @categoryId
|
||||||
|
AND a.parentarticle_id IS NULL
|
||||||
|
ORDER BY a.id ASC
|
||||||
|
`;
|
||||||
|
|
||||||
export const RESOLVE_CATEGORY_ACCESS = `
|
export const RESOLVE_CATEGORY_ACCESS = `
|
||||||
WITH RECURSIVE ancestors AS (
|
WITH RECURSIVE ancestors AS (
|
||||||
SELECT id, parent_id
|
SELECT id, parent_id
|
||||||
|
|||||||
207
src/store/category/generateImage.js
Normal file
207
src/store/category/generateImage.js
Normal file
@@ -0,0 +1,207 @@
|
|||||||
|
import {
|
||||||
|
GET_CATEGORY_ARTICLE_PICS as MSSQL_GET_CATEGORY_ARTICLE_PICS,
|
||||||
|
LIST_CHILD_CATEGORIES as MSSQL_LIST_CHILD_CATEGORIES,
|
||||||
|
} from '../../providers/mssql/queries/category.js';
|
||||||
|
import {
|
||||||
|
GET_CATEGORY_ARTICLE_PICS as SQLITE_GET_CATEGORY_ARTICLE_PICS,
|
||||||
|
LIST_CHILD_CATEGORIES as SQLITE_LIST_CHILD_CATEGORIES,
|
||||||
|
} from '../../providers/sqlite3/queries/category.js';
|
||||||
|
import resolveCategoryAccess from './resolveCategoryAccess.js';
|
||||||
|
import addImage from './addImage.js';
|
||||||
|
import makeRunner from '../runner.js';
|
||||||
|
import sharp from 'sharp';
|
||||||
|
|
||||||
|
export default function generateImage(provider) {
|
||||||
|
const access = resolveCategoryAccess(provider);
|
||||||
|
const isSqlite = provider?.dialect === 'sqlite3';
|
||||||
|
const getPicsSql = isSqlite ? SQLITE_GET_CATEGORY_ARTICLE_PICS : MSSQL_GET_CATEGORY_ARTICLE_PICS;
|
||||||
|
const listChildrenSql = isSqlite ? SQLITE_LIST_CHILD_CATEGORIES : MSSQL_LIST_CHILD_CATEGORIES;
|
||||||
|
const addCategoryPic = addImage(provider);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generates a mosaic image directly from the category's and its subcategories' articles
|
||||||
|
* (grouped by subcategory) and sets it as the new main picture for the category.
|
||||||
|
*
|
||||||
|
* @param {number} googleAuthId - The id of the Google auth user.
|
||||||
|
* @param {number|object} inputOrCategoryId - The id of the category or an options object.
|
||||||
|
* @param {object|number} [maybeOptsOrSize={}] - Options object or numeric size.
|
||||||
|
* @param {number} [maybeOptsOrSize.size] - Grid size (2 for 2x2, 3 for 3x3, 4 for 4x4). Defaults to 3 (falls back to 2 if < 7 images).
|
||||||
|
* @param {object} [maybeTx] - Optional transaction to run within.
|
||||||
|
* @returns {Promise<{id: number, fullHash: string, thumbW: number, thumbH: number, thumbHash: string, smallThumbW: number, smallThumbH: number, size: number, grid: string, articleCount: number}>} The new main image info.
|
||||||
|
* @throws {Error} If the category is not found, not authorized, size is invalid, or no images are found.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* const result = await store.category.generateImage(42, 5, { size: 3 });
|
||||||
|
* // => { id: 5, fullHash, thumbW, thumbH, thumbHash, smallThumbW, smallThumbH, size: 3, grid: '3x3', articleCount: 12 }
|
||||||
|
*/
|
||||||
|
return async (googleAuthId, inputOrCategoryId, maybeOptsOrSize = {}, maybeTx) => {
|
||||||
|
let categoryId;
|
||||||
|
let size = null;
|
||||||
|
let tx;
|
||||||
|
|
||||||
|
if (typeof inputOrCategoryId === 'object' && inputOrCategoryId !== null) {
|
||||||
|
categoryId = inputOrCategoryId.categoryId ?? inputOrCategoryId.id;
|
||||||
|
size = inputOrCategoryId.size ?? null;
|
||||||
|
tx = maybeOptsOrSize;
|
||||||
|
} else {
|
||||||
|
categoryId = inputOrCategoryId;
|
||||||
|
if (typeof maybeOptsOrSize === 'number') {
|
||||||
|
size = maybeOptsOrSize;
|
||||||
|
tx = maybeTx;
|
||||||
|
} else if (typeof maybeOptsOrSize === 'object' && maybeOptsOrSize !== null) {
|
||||||
|
size = maybeOptsOrSize.size ?? null;
|
||||||
|
tx = maybeTx;
|
||||||
|
} else {
|
||||||
|
tx = maybeTx;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (size != null) {
|
||||||
|
size = Number(size);
|
||||||
|
if (![2, 3, 4].includes(size)) {
|
||||||
|
throw new Error('Invalid size, must be 2, 3, or 4');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const runGenerate = async (runner) => {
|
||||||
|
await access(googleAuthId, categoryId, runner);
|
||||||
|
|
||||||
|
// Collect category and descendant subcategories in DFS order
|
||||||
|
async function getCategoryTreeIds(parentId) {
|
||||||
|
const result = [parentId];
|
||||||
|
const children = await runner.query(listChildrenSql, { parentId });
|
||||||
|
for (const child of children.rows) {
|
||||||
|
const sub = await getCategoryTreeIds(child.id);
|
||||||
|
result.push(...sub);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
const categoryIds = await getCategoryTreeIds(categoryId);
|
||||||
|
|
||||||
|
// Collect distinct articles with images grouped by subcategory
|
||||||
|
const seenArticleIds = new Set();
|
||||||
|
const seenHashes = new Set();
|
||||||
|
const candidates = [];
|
||||||
|
|
||||||
|
for (const catId of categoryIds) {
|
||||||
|
const articlePics = await runner.query(getPicsSql, { categoryId: catId });
|
||||||
|
for (const row of articlePics.rows) {
|
||||||
|
if (!row.hash) continue;
|
||||||
|
if (seenArticleIds.has(row.articleId) || seenHashes.has(row.hash)) continue;
|
||||||
|
seenArticleIds.add(row.articleId);
|
||||||
|
seenHashes.add(row.hash);
|
||||||
|
candidates.push({
|
||||||
|
articleId: row.articleId,
|
||||||
|
picId: row.picId,
|
||||||
|
hash: row.hash,
|
||||||
|
buffer: row.buffer,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (candidates.length === 0) {
|
||||||
|
throw new Error('No images found for category articles');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine matrix size:
|
||||||
|
// Default to 3x3, fallback to 2x2 if fewer than 7 images are available.
|
||||||
|
let targetSize = size;
|
||||||
|
if (!targetSize) {
|
||||||
|
targetSize = candidates.length < 7 ? 2 : 3;
|
||||||
|
} else if (targetSize === 3 && candidates.length < 7) {
|
||||||
|
targetSize = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
const cellCount = targetSize * targetSize;
|
||||||
|
const selected = candidates.length >= cellCount
|
||||||
|
? candidates.slice(0, cellCount)
|
||||||
|
: Array.from({ length: cellCount }, (_, i) => candidates[i % candidates.length]);
|
||||||
|
|
||||||
|
// Load image buffers for the selected items
|
||||||
|
const loaded = await Promise.all(
|
||||||
|
selected.map(async (item) => {
|
||||||
|
let buf = item.buffer;
|
||||||
|
if (!buf || !buf.length) {
|
||||||
|
if (isSqlite) {
|
||||||
|
buf = runner.readPicFull ? await runner.readPicFull(item.hash) : await provider?.readPicFull?.(item.hash);
|
||||||
|
if (!buf) {
|
||||||
|
buf = runner.readPicThumb ? await runner.readPicThumb(item.hash) : await provider?.readPicThumb?.(item.hash);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const picRes = await runner.query(
|
||||||
|
'SELECT fullbinary, thumbbinary FROM pic WHERE hash = @hash',
|
||||||
|
{ hash: item.hash },
|
||||||
|
);
|
||||||
|
buf = picRes.rows[0]?.fullbinary ?? picRes.rows[0]?.thumbbinary;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { ...item, buffer: buf };
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const validPics = loaded.filter(
|
||||||
|
(p) => p.buffer && Buffer.isBuffer(p.buffer) && p.buffer.length > 0,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (validPics.length === 0) {
|
||||||
|
throw new Error('No images found for category articles');
|
||||||
|
}
|
||||||
|
|
||||||
|
const finalPics = Array.from(
|
||||||
|
{ length: cellCount },
|
||||||
|
(_, i) => validPics[i % validPics.length],
|
||||||
|
);
|
||||||
|
|
||||||
|
const TOTAL_SIZE = 1200;
|
||||||
|
const tileSize = TOTAL_SIZE / targetSize;
|
||||||
|
|
||||||
|
const composites = await Promise.all(
|
||||||
|
finalPics.map(async (pic, idx) => {
|
||||||
|
const row = Math.floor(idx / targetSize);
|
||||||
|
const col = idx % targetSize;
|
||||||
|
const tileBuffer = await sharp(pic.buffer)
|
||||||
|
.resize(tileSize, tileSize, { fit: 'cover', position: 'center' })
|
||||||
|
.toBuffer();
|
||||||
|
return {
|
||||||
|
input: tileBuffer,
|
||||||
|
left: col * tileSize,
|
||||||
|
top: row * tileSize,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const mosaicBuffer = await sharp({
|
||||||
|
create: {
|
||||||
|
width: TOTAL_SIZE,
|
||||||
|
height: TOTAL_SIZE,
|
||||||
|
channels: 4,
|
||||||
|
background: { r: 255, g: 255, b: 255, alpha: 1 },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.composite(composites)
|
||||||
|
.png()
|
||||||
|
.toBuffer();
|
||||||
|
|
||||||
|
// Clear existing categorypic rows so the new mosaic becomes the main (first) image
|
||||||
|
await runner.query('DELETE FROM categorypic WHERE category_id = @categoryId', { categoryId });
|
||||||
|
|
||||||
|
const added = await addCategoryPic(googleAuthId, categoryId, mosaicBuffer, runner);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...added,
|
||||||
|
size: targetSize,
|
||||||
|
grid: `${targetSize}x${targetSize}`,
|
||||||
|
articleCount: candidates.length,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
if (tx) {
|
||||||
|
return runGenerate(makeRunner(provider, tx));
|
||||||
|
}
|
||||||
|
if (typeof provider?.transaction === 'function') {
|
||||||
|
return provider.transaction(runGenerate);
|
||||||
|
}
|
||||||
|
return runGenerate(makeRunner(provider));
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -345,4 +345,80 @@ describe('store.category', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('generateImage', () => {
|
||||||
|
it('rejects invalid size', async () => {
|
||||||
|
const cat = await create('Gen Invalid Size');
|
||||||
|
await assert.rejects(
|
||||||
|
() => store.category.generateImage(tenant.googleAuthId, cat.id, { size: 5 }),
|
||||||
|
{ message: 'Invalid size, must be 2, 3, or 4' },
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects category without any article images', async () => {
|
||||||
|
const cat = await create('Gen Empty Cat');
|
||||||
|
await assert.rejects(
|
||||||
|
() => store.category.generateImage(tenant.googleAuthId, cat.id),
|
||||||
|
{ message: 'No images found for category articles' },
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to 2x2 when fewer than 7 images are available', async () => {
|
||||||
|
const cat = await create('Mosaic Parent Cat');
|
||||||
|
const sub = await create('Mosaic Sub Cat', cat.id);
|
||||||
|
|
||||||
|
// Create 2 articles in parent, 2 articles in subcategory with images
|
||||||
|
for (let i = 0; i < 2; i++) {
|
||||||
|
const art = await createArticle(store, tenant, { parentId: cat.id, name: `Parent Art ${i}` });
|
||||||
|
await store.article.addImage(tenant.googleAuthId, art.sku, await makePng({ r: 50 * i, g: 100, b: 150 }));
|
||||||
|
}
|
||||||
|
for (let i = 0; i < 2; i++) {
|
||||||
|
const art = await createArticle(store, tenant, { parentId: sub.id, name: `Sub Art ${i}` });
|
||||||
|
await store.article.addImage(tenant.googleAuthId, art.sku, await makePng({ r: 150, g: 50 * i, b: 200 }));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4 images available (< 7), default without size should fall back to 2x2
|
||||||
|
const res = await store.category.generateImage(tenant.googleAuthId, cat.id);
|
||||||
|
assert.equal(res.id, cat.id);
|
||||||
|
assert.equal(res.size, 2);
|
||||||
|
assert.equal(res.grid, '2x2');
|
||||||
|
assert.equal(res.articleCount, 4);
|
||||||
|
assert.ok(res.fullHash);
|
||||||
|
assert.ok(res.thumbW > 0);
|
||||||
|
|
||||||
|
// Category tree exposes the new mosaic image
|
||||||
|
const treeCat = await findInTree(cat.id);
|
||||||
|
assert.equal(treeCat.fullHash, res.fullHash);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses 3x3 when 7 or more images are available or explicit size=3', async () => {
|
||||||
|
const cat = await create('Mosaic 3x3 Cat');
|
||||||
|
for (let i = 0; i < 7; i++) {
|
||||||
|
const art = await createArticle(store, tenant, { parentId: cat.id, name: `3x3 Art ${i}` });
|
||||||
|
await store.article.addImage(tenant.googleAuthId, art.sku, await makePng({ r: 30 * i, g: 70, b: 90 }));
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await store.category.generateImage(tenant.googleAuthId, cat.id);
|
||||||
|
assert.equal(res.id, cat.id);
|
||||||
|
assert.equal(res.size, 3);
|
||||||
|
assert.equal(res.grid, '3x3');
|
||||||
|
assert.equal(res.articleCount, 7);
|
||||||
|
assert.ok(res.fullHash);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('supports explicit size parameter (e.g. size=4)', async () => {
|
||||||
|
const cat = await create('Mosaic 4x4 Cat');
|
||||||
|
for (let i = 0; i < 4; i++) {
|
||||||
|
const art = await createArticle(store, tenant, { parentId: cat.id, name: `4x4 Art ${i}` });
|
||||||
|
await store.article.addImage(tenant.googleAuthId, art.sku, await makePng({ r: 40 * i, g: 120, b: 50 }));
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await store.category.generateImage(tenant.googleAuthId, cat.id, { size: 4 });
|
||||||
|
assert.equal(res.id, cat.id);
|
||||||
|
assert.equal(res.size, 4);
|
||||||
|
assert.equal(res.grid, '4x4');
|
||||||
|
assert.ok(res.fullHash);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user