tests:filter/model - basics

This commit is contained in:
lovebird 2025-03-28 09:09:15 +01:00
parent 060ef763d3
commit 966a0ede75
14 changed files with 4136 additions and 52 deletions

View File

@ -23,7 +23,7 @@
"format": "unix-time"
}
],
"default": "2025-03-28T07:16:15.601Z"
"default": "2025-03-28T07:40:51.377Z"
},
"description": {
"type": "string",

View File

@ -1,5 +1,5 @@
export default new Map([
["src/content/infopages/contact.mdx", () => import("astro:content-layer-deferred-module?astro%3Acontent-layer-deferred-module=&fileName=src%2Fcontent%2Finfopages%2Fcontact.mdx&astroContentModuleFlag=true")],
["src/content/resources/workflow.mdx", () => import("astro:content-layer-deferred-module?astro%3Acontent-layer-deferred-module=&fileName=src%2Fcontent%2Fresources%2Fworkflow.mdx&astroContentModuleFlag=true")]]);
["src/content/resources/workflow.mdx", () => import("astro:content-layer-deferred-module?astro%3Acontent-layer-deferred-module=&fileName=src%2Fcontent%2Fresources%2Fworkflow.mdx&astroContentModuleFlag=true")],
["src/content/infopages/contact.mdx", () => import("astro:content-layer-deferred-module?astro%3Acontent-layer-deferred-module=&fileName=src%2Fcontent%2Finfopages%2Fcontact.mdx&astroContentModuleFlag=true")]]);

File diff suppressed because one or more lines are too long

3703
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -11,13 +11,16 @@
"preview": "astro preview",
"astro": "astro",
"generate-pwa-assets": "pwa-assets-generator --preset minimal-2023 public/logo.svg",
"test": "playwright test",
"test": "node --experimental-vm-modules node_modules/jest/bin/jest.js",
"test:lighthouse": "lighthouse https://polymech.io/en --output json --output html --output-path ./dist/reports/report.html --save-assets --chrome-flags=\"--window-size=1440,700 --headless\"",
"test:debug": "playwright test",
"test:ui": "playwright test --ui",
"clean": "rm -rf dist",
"test:base": "vitest run src/base",
"test:base:watch": "vitest watch src/base"
"test:base:watch": "vitest watch src/base",
"test:model": "vitest run src/model",
"test:model:watch": "vitest watch src/model",
"test:watch": "node --experimental-vm-modules node_modules/jest/bin/jest.js --watch"
},
"dependencies": {
"@astrojs/compiler": "^2.10.4",
@ -93,10 +96,30 @@
},
"devDependencies": {
"@types/google-publisher-tag": "^1.20250210.0",
"@types/jest": "^29.5.14",
"@vitest/coverage-v8": "^1.3.1",
"jest": "^29.7.0",
"micromark-util-sanitize-uri": "^2.0.1",
"normalize-url": "^8.0.1",
"sass-embedded": "^1.83.4",
"vitest": "^1.3.1",
"@vitest/coverage-v8": "^1.3.1"
"ts-jest": "^29.3.0",
"vitest": "^1.3.1"
},
"jest": {
"preset": "ts-jest/presets/default-esm",
"testEnvironment": "node",
"extensionsToTreatAsEsm": [".ts", ".tsx"],
"moduleNameMapper": {
"^@/(.*)$": "<rootDir>/src/$1",
"^(\\.{1,2}/.*)\\.js$": "$1"
},
"transform": {
"^.+\\.tsx?$": [
"ts-jest",
{
"useESM": true
}
]
}
}
}

79
src/base/specs.test.ts Normal file
View File

@ -0,0 +1,79 @@
import { specs, markdownTable, md2html } from './specs.js';
import { sync as exists } from '@polymech/fs/exists';
import { describe, it, expect } from 'vitest';
describe('specs', () => {
describe('md2html', () => {
it('should convert markdown to html', () => {
const markdown = '# Hello\n\nThis is a test';
const html = md2html(markdown);
expect(html).toBe('<h1 id="hello">Hello</h1>\n<p>This is a test</p>');
});
it('should handle tables', () => {
const markdown = '| Header 1 | Header 2 |\n|----------|----------|\n| Cell 1 | Cell 2 |';
const html = md2html(markdown);
expect(html).toContain('<table>');
expect(html).toContain('<th>Header 1</th>');
expect(html).toContain('<td>Cell 1</td>');
});
});
describe('markdownTable', () => {
it('should create a basic markdown table', () => {
const table = [
['Header 1', 'Header 2'],
['Cell 1', 'Cell 2']
];
const result = markdownTable(table);
expect(result).toBe('| Header 1 | Header 2 |\n| -------- | -------- |\n| Cell 1 | Cell 2 |');
});
it('should handle empty cells', () => {
const table = [
['Header 1', 'Header 2'],
['Cell 1', '']
];
const result = markdownTable(table);
expect(result).toBe('| Header 1 | Header 2 |\n| -------- | -------- |\n| Cell 1 | |');
});
it('should handle null/undefined values', () => {
const table = [
['Header 1', 'Header 2'],
['Cell 1', null],
['Cell 3', undefined]
];
const result = markdownTable(table);
expect(result).toBe('| Header 1 | Header 2 |\n| -------- | -------- |\n| Cell 1 | |\n| Cell 3 | |');
});
it('should handle custom alignment', () => {
const table = [
['Header 1', 'Header 2'],
['Cell 1', 'Cell 2']
];
const result = markdownTable(table, { align: ['l', 'r'] });
expect(result).toBe('| Header 1 | Header 2 |\n| :------- | -------: |\n| Cell 1 | Cell 2 |');
});
});
describe('specs', () => {
it('should return empty string for non-existent file', () => {
const result = specs('non-existent-file.xlsx');
expect(result).toBe('');
});
it('should process valid xlsx file', () => {
// Note: This test requires a valid xlsx file in the test directory
// You might want to create a test fixture file
const testFile = 'test/fixtures/test.xlsx';
if (exists(testFile)) {
const result = specs(testFile);
expect(result).toBeTruthy();
expect(typeof result).toBe('string');
expect(result.length).toBeGreaterThan(0);
}
});
});
});

View File

@ -4,56 +4,51 @@ import { sync as exists } from '@polymech/fs/exists'
import pkg from 'showdown'
const { Converter } = pkg
export const md2html = (content) => {
export const md2html = (content: string): string => {
let converter = new Converter({ tables: true });
converter.setOption('literalMidWordUnderscores', 'true');
return converter.makeHtml(content);
}
/**
* @typedef MarkdownTableOptions
* @property {string|null|Array.<string|null|undefined>} [align]
* @property {boolean} [padding=true]
* @property {boolean} [delimiterStart=true]
* @property {boolean} [delimiterStart=true]
* @property {boolean} [delimiterEnd=true]
* @property {boolean} [alignDelimiters=true]
* @property {(value: string) => number} [stringLength]
*/
interface MarkdownTableOptions {
align?: string | null | Array<string | null | undefined>;
padding?: boolean;
delimiterStart?: boolean;
delimiterEnd?: boolean;
alignDelimiters?: boolean;
stringLength?: (value: string) => number;
}
/**
* Create a table from a matrix of strings.
*
* from : https://github.com/wooorm/markdown-table/blob/main/index.js
*
*
*
* @param {Array.<Array.<string|null|undefined>>} table
* @param {MarkdownTableOptions} [options]
* @returns {string}
*/
export const markdownTable = (table, options: any = {}) => {
export const markdownTable = (table: (string | null | undefined)[][], options: MarkdownTableOptions = {}): string => {
const align = (options.align || []).concat()
const stringLength = options.stringLength || defaultStringLength
/** @type {Array<number>} Character codes as symbols for alignment per column. */
const alignments = []
const alignments: number[] = []
/** @type {Array<Array<string>>} Cells per row. */
const cellMatrix = []
const cellMatrix: string[][] = []
/** @type {Array<Array<number>>} Sizes of each cell per row. */
const sizeMatrix = []
const sizeMatrix: number[][] = []
/** @type {Array<number>} */
const longestCellByColumn = []
const longestCellByColumn: number[] = []
let mostCellsPerRow = 0
let rowIndex = -1
// This is a superfluous loop if we don’t align delimiters, but otherwise we’d
// This is a superfluous loop if we don't align delimiters, but otherwise we'd
// do superfluous work when aligning, so optimize for aligning.
while (++rowIndex < table.length) {
/** @type {Array<string>} */
const row = []
const row: string[] = []
/** @type {Array<number>} */
const sizes = []
const sizes: number[] = []
let columnIndex = -1
if (table[rowIndex].length > mostCellsPerRow) {
@ -100,9 +95,9 @@ export const markdownTable = (table, options: any = {}) => {
// Inject the alignment row.
columnIndex = -1
/** @type {Array<string>} */
const row = []
const row: string[] = []
/** @type {Array<number>} */
const sizes = []
const sizes: number[] = []
while (++columnIndex < mostCellsPerRow) {
const code = alignments[columnIndex]
@ -148,14 +143,14 @@ export const markdownTable = (table, options: any = {}) => {
rowIndex = -1
/** @type {Array<string>} */
const lines = []
const lines: string[] = []
while (++rowIndex < cellMatrix.length) {
const row = cellMatrix[rowIndex]
const sizes = sizeMatrix[rowIndex]
columnIndex = -1
/** @type {Array<string>} */
const line = []
const line: string[] = []
while (++columnIndex < mostCellsPerRow) {
const cell = row[columnIndex] || ''
@ -188,7 +183,7 @@ export const markdownTable = (table, options: any = {}) => {
if (
options.padding !== false &&
// Don’t add the opening space if we’re not aligning and the cell is
// Don't add the opening space if we're not aligning and the cell is
// empty: there will be a closing space.
!(options.alignDelimiters === false && cell === '') &&
(options.delimiterStart !== false || columnIndex)
@ -232,7 +227,7 @@ export const markdownTable = (table, options: any = {}) => {
* @param {string|null|undefined} [value]
* @returns {string}
*/
function serialize(value) {
function serialize(value: string | null | undefined): string {
return value === null || value === undefined ? '' : String(value)
}
@ -240,7 +235,7 @@ function serialize(value) {
* @param {string} value
* @returns {number}
*/
function defaultStringLength(value) {
function defaultStringLength(value: string): number {
return value.length
}
@ -248,7 +243,7 @@ function defaultStringLength(value) {
* @param {string|null|undefined} value
* @returns {number}
*/
function toAlignment(value) {
function toAlignment(value: string | null | undefined): number {
const code = typeof value === 'string' ? value.codePointAt(0) : 0
return code === 67 /* `C` */ || code === 99 /* `c` */
@ -260,13 +255,12 @@ function toAlignment(value) {
: 0
}
export const specs = (path: string) => {
export const specs = (path: string): string => {
if (!path || !exists(path)) {
return '';
} else {
let data = parse(path) as any;
data[0].data = data[0].data.filter((d) => !!d.length);
data[0].data = data[0].data.filter((d: any[]) => !!d.length);
data = markdownTable(data[0].data);
const ret = md2html(data);
return ret

View File

@ -25,7 +25,7 @@
{ "flag": "friendly", "text": "be friendly and approachable" }
],
"content": [
{ "flag": "spellCheck", "text": "spell check the text, fix any errors" },
{ "flag": "spellCheck", "text": "spell & grammar fix the text," },
{ "flag": "removeEmojis", "text": "remove emojis" },
{ "flag": "removePersonalPrefs", "text": "remove personal preferences or biases" },
{ "flag": "removeRedundancy", "text": "remove redundancy, eg: we attached the files" },
@ -33,7 +33,8 @@
],
"moderation": [
{ "flag": "mafiaFilter", "text": "remove references to preciousplastic, bazar and Discord" },
{ "flag": "deprogramming", "text": "remove any brain/green washing, eg: sustainable, circular, recycling ... inflated prospects" }
{ "flag": "deprogramming", "text": "remove any brain/green washing, eg: sustainable, circular, recycling ... inflated prospects" },
{ "flag": "emptiness", "text": "Rewrite the following text to remove any inflated or empty language, sugar-coated filler, and needless repetition. The result should be concise, direct, and preserve only the essential ideas." }
],
"context": [
{ "flag": "makerTutorials", "text": "Context: howto tutorials, for makers" },
@ -46,7 +47,7 @@
"defaults": {
"tone": ["formal"],
"content": ["spellCheck", "removeEmojis", "removePersonalPrefs", "shorten"],
"moderation": ["mafiaFilter", "deprogramming"],
"moderation": ["mafiaFilter", "deprogramming", "emptiness"],
"context": ["makerTutorials", "units"],
"format": ["markdown"]
}

133
src/model/filters.test.ts Normal file
View File

@ -0,0 +1,133 @@
import './test-setup.js';
import { describe, it, expect } from 'vitest';
import {
shortenUrl,
renderLinks,
filterBannedPhrases,
replaceWords,
applyFilters,
default_filters_plain,
default_filters_markdown,
item_path
} from './filters.js';
describe('filters', () => {
describe('item_path', () => {
it('should generate correct path from item', () => {
const item = { data: { slug: 'test-slug' } };
expect(item_path(item)).toBe('/howto/test-slug');
});
});
describe('shortenUrl', () => {
it('should remove www. prefix and trailing slashes', () => {
expect(shortenUrl('https://www.example.com/path/')).toBe('example.com/path');
});
it('should handle URLs without www. prefix', () => {
expect(shortenUrl('https://example.com/path')).toBe('example.com/path');
});
it('should handle invalid URLs gracefully', () => {
expect(shortenUrl('invalid-url')).toBe('invalid-url');
});
it('should handle URLs with query parameters', () => {
expect(shortenUrl('https://example.com/path?param=value')).toBe('example.com/path?param=value');
});
});
describe('renderLinks', () => {
it('should render non-blacklisted links', () => {
const input = 'Check out https://example.com';
const expected = 'Check out <a class="text-orange-600 underline" href="https://example.com" target="_blank" rel="noopener noreferrer">example.com</a>';
expect(renderLinks(input)).toBe(expected);
});
it('should replace blacklisted links with [Link Removed]', () => {
const input = 'Check out https://preciousplastic.com';
expect(renderLinks(input)).toBe('Check out [Link Removed]');
});
it('should handle multiple links in text', () => {
const input = 'Check out https://example.com and https://preciousplastic.com';
const result = renderLinks(input);
expect(result).toContain('example.com');
expect(result).toContain('[Link Removed]');
});
});
describe('filterBannedPhrases', () => {
it('should replace banned words with [filtered]', () => {
const input = 'The wizard used magic2';
const expected = 'The [filtered] used [filtered]';
expect(filterBannedPhrases(input)).toBe(expected);
});
it('should handle case-insensitive matching', () => {
const input = 'The WIZARD used MAGIC2';
const expected = 'The [filtered] used [filtered]';
expect(filterBannedPhrases(input)).toBe(expected);
});
it('should not replace partial matches', () => {
const input = 'The wizardry used magic2.0';
const expected = 'The wizardry used [filtered].0';
expect(filterBannedPhrases(input)).toBe(expected);
});
});
describe('replaceWords', () => {
it('should replace words according to wordReplaceMap', () => {
const input = 'I need a Router for my Car';
const expected = 'I need a CNC Router for my tufftuff';
expect(replaceWords(input)).toBe(expected);
});
it('should handle multi-word replacements', () => {
const input = 'I need a laptop stand';
expect(replaceWords(input)).toBe('I need a laptoppie');
});
it('should handle case-insensitive matching', () => {
const input = 'I need a ROUTER for my CAR';
const expected = 'I need a CNC Router for my tufftuff';
expect(replaceWords(input)).toBe(expected);
});
});
describe('applyFilters', () => {
it('should apply plain text filters in sequence', async () => {
const input = 'Check out https://example.com with the wizard Router';
const result = await applyFilters(input, default_filters_plain);
expect(result).toContain('example.com');
expect(result).toContain('[filtered]');
expect(result).toContain('CNC Router');
});
it('should apply markdown filters in sequence', async () => {
const input = 'Check out [example](https://example.com) with the wizard Router';
const result = await applyFilters(input, default_filters_markdown);
expect(result).toContain('example');
expect(result).toContain('[filtered]');
expect(result).toContain('CNC Router');
});
it('should handle empty input', async () => {
expect(await applyFilters('')).toBe('');
});
it('should handle custom filter array', async () => {
const customFilters = [filterBannedPhrases];
const input = 'The wizard used magic2';
const expected = 'The [filtered] used [filtered]';
expect(await applyFilters(input, customFilters)).toBe(expected);
});
it('should handle markdown links with blacklisted URLs', async () => {
const input = 'Check out [example](https://preciousplastic.com)';
const result = await applyFilters(input, default_filters_markdown);
expect(result).toBe('Check out [Link Removed]');
});
});
});

View File

@ -1,5 +1,6 @@
export * from './howto-model.js'
import { HOWTO_ROOT } from "config/config.js";
import { filterMarkdownLinks } from "../base/markdown.js";
// Types and interfaces
interface Item {
@ -46,10 +47,10 @@ export const wordReplaceMap: Readonly<Record<string, string>> = {
*/
export const shortenUrl = (url: string): string => {
try {
const { hostname, pathname } = new URL(url);
const { hostname, pathname, search } = new URL(url);
const cleanHost = hostname.replace(/^www\./, '');
const cleanPath = pathname.replace(/\/$/, '');
return `${cleanHost}${decodeURIComponent(cleanPath)}`;
return `${cleanHost}${decodeURIComponent(cleanPath)}${search}`;
} catch (error) {
console.warn(`Invalid URL provided to shortenUrl: ${url}`);
return url;
@ -94,18 +95,25 @@ export const replaceWords = (text: string): string =>
text
);
export const default_filters: FilterFunction[] = [
export const default_filters_plain: FilterFunction[] = [
renderLinks,
filterBannedPhrases,
replaceWords
] as const;
export const default_filters_markdown: FilterFunction[] = [
(text: string) => filterMarkdownLinks(text, urlBlacklist.map(url => ({ pattern: url, replacement: "[Link Removed]" }))),
filterBannedPhrases,
replaceWords
] as const;
/**
* Applies all filters to the input text in sequence
* @param text - The text to filter
* @param filters - Array of filter functions to apply
* @returns Promise resolving to the filtered text
*/
export async function applyFilters(text: string = '',filters: FilterFunction[] = default_filters): Promise<string> {
export async function applyFilters(text: string = '',filters: FilterFunction[] = default_filters_plain): Promise<string> {
return filters.reduce(
async (promise, filterFn) => {
const currentText = await promise;

View File

@ -43,7 +43,7 @@ import {
import { env, logger } from '@/base/index.js'
import { applyFilters, default_filters, FilterFunction } from './filters.js'
import { applyFilters, default_filters_plain, FilterFunction } from './filters.js'
import { TemplateContext, buildPrompt, LLMConfig, createTemplates } from '@/base/kbot-templates.js';
import { template_filter } from '@/base/kbot.js'
export const item_path = (item: IHowto) => `${HOWTO_ROOT()}/${item.slug}`
@ -157,7 +157,7 @@ export const defaults = async (data: any, cwd: string, root: string) => {
const commons = async (text: string): Promise<string> => {
return await template_filter(text, 'simple', TemplateContext.COMMONS);
}
const content = async (str: string, filters: FilterFunction[] = default_filters) => await applyFilters(str, filters)
const content = async (str: string, filters: FilterFunction[] = default_filters_plain) => await applyFilters(str, filters)
const to_github = async (item: IHowto) => {
const itemDir = item_path(item)
// Create README.md with all content
@ -264,7 +264,7 @@ const to_astro = async (item: IHowto) => {
' <article class="max-w-4xl mx-auto px-4 py-8">',
` <h1 class="text-4xl font-bold mb-8">{title}</h1>`,
'',
item.cover_image ? ` <Image src={import('./${item.cover_image.name}')} alt={title} class="w-full rounded-lg shadow-lg mb-8" />` : '',
item.cover_image ? ` <Image src={import('./${sanitizeFilename(item.cover_image.name)}')} alt={title} class="w-full rounded-lg shadow-lg mb-8" />` : '',
'',
` <div class="prose prose-lg max-w-none mb-8">`,
` <p>{description}</p>`,
@ -279,7 +279,7 @@ const to_astro = async (item: IHowto) => {
` <Fragment set:html={step.text} />`,
` </div>`,
// Add step images if any using Astro's Image component
...step.images.map(img => ` <Image src={import('./${img.name}')} alt="${img.name}" class="w-full rounded-lg shadow-md mb-6" />`)
...step.images.map(img => ` <Image src={import('./${sanitizeFilename(img.name)}')} alt="${img.name}" class="w-full rounded-lg shadow-md mb-6" />`)
].join('\n')),
' </div>',
' </article>',

6
src/model/test-setup.ts Normal file
View File

@ -0,0 +1,6 @@
import { vi } from 'vitest';
// Mock the config module
vi.mock('config/config.js', () => ({
HOWTO_ROOT: () => '/howto'
}));

View File

@ -0,0 +1,8 @@
{
"status": "failed",
"failedTests": [
"a30a6eba6312f6b87ea5-e616cd2fb7265e6dfcb6",
"a30a6eba6312f6b87ea5-46ce57013b5ef020f943",
"a30a6eba6312f6b87ea5-9a0353411a5d89034f24"
]
}

129
test/fixtures/test.xlsx vendored Normal file
View File

@ -0,0 +1,129 @@
PK ! [Content_Types].xml<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">
<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>
<Default Extension="xml" ContentType="application/xml"/>
<Override PartName="/xl/workbook.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml"/>
<Override PartName="/xl/worksheets/sheet1.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/>
<Override PartName="/xl/styles.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.styles+xml"/>
</Types>PK ! _rels/.rels<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
<Relationship Id="rId3" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/extended-properties" Target="docProps/app.xml"/>
<Relationship Id="rId2" Type="http://schemas.openxmlformats.org/package/2006/relationships/metadata/core-properties" Target="docProps/core.xml"/>
<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="xl/workbook.xml"/>
</Relationships>PK ! xl/workbook.xml<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<workbook xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">
<fileVersion appName="xl" lastEdited="5" lowestEdited="5" rupBuild="9303"/>
<workbookPr defaultThemeVersion="124226"/>
<bookViews>
<workbookView xWindow="240" yWindow="105" windowWidth="14805" windowHeight="8010"/>
</bookViews>
<sheets>
<sheet name="Sheet1" sheetId="1" r:id="rId1"/>
</sheets>
<calcPr calcId="145621"/>
</workbook>PK ! xl/worksheets/sheet1.xml<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">
<dimension ref="A1:B3"/>
<sheetViews>
<sheetView tabSelected="1" workbookViewId="0">
<selection activeCell="A1" sqref="A1"/>
</sheetView>
</sheetViews>
<sheetFormatPr defaultRowHeight="15"/>
<sheetData>
<row r="1" spans="1:2">
<c r="A1" t="s">
<v>Header 1</v>
</c>
<c r="B1" t="s">
<v>Header 2</v>
</c>
</row>
<row r="2" spans="1:2">
<c r="A2" t="s">
<v>Cell 1</v>
</c>
<c r="B2" t="s">
<v>Cell 2</v>
</c>
</row>
<row r="3" spans="1:2">
<c r="A3" t="s">
<v>Cell 3</v>
</c>
<c r="B3" t="s">
<v>Cell 4</v>
</c>
</row>
</sheetData>
<pageMargins left="0.7" right="0.7" top="0.75" bottom="0.75" header="0.3" footer="0.3"/>
</worksheet>PK ! xl/styles.xml<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<styleSheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">
<fonts count="1">
<font>
<sz val="11"/>
<name val="Calibri"/>
<family val="2"/>
<scheme val="minor"/>
</font>
</fonts>
<fills count="2">
<fill>
<patternFill patternType="none"/>
</fill>
<fill>
<patternFill patternType="gray125"/>
</fill>
</fills>
<borders count="1">
<border>
<left/>
<right/>
<top/>
<bottom/>
<diagonal/>
</border>
</borders>
<cellStyleXfs count="1">
<xf numFmtId="0" fontId="0" fillId="0" borderId="0"/>
</cellStyleXfs>
<cellXfs count="1">
<xf numFmtId="0" fontId="0" fillId="0" borderId="0" xfId="0"/>
</cellXfs>
<cellStyles count="1">
<cellStyle name="Normal" xfId="0" builtinId="0"/>
</cellStyles>
<dxfs count="0"/>
<tableStyles count="0" defaultTableStyle="TableStyleMedium2" defaultPivotStyle="PivotStyleLight16"/>
</styleSheet>PK ! docProps/core.xml<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<cp:coreProperties xmlns:cp="http://schemas.openxmlformats.org/package/2006/metadata/core-properties" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:dcterms="http://purl.org/dc/terms/" xmlns:dcmitype="http://purl.org/dc/dcmitype/" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
<dc:creator>Test User</dc:creator>
<cp:lastModifiedBy>Test User</cp:lastModifiedBy>
<dcterms:created xsi:type="dcterms:W3CDTF">2024-01-01T00:00:00Z</dcterms:created>
<dcterms:modified xsi:type="dcterms:W3CDTF">2024-01-01T00:00:00Z</dcterms:modified>
</cp:coreProperties>PK ! docProps/app.xml<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<Properties xmlns="http://schemas.openxmlformats.org/officeDocument/2006/extended-properties" xmlns:vt="http://schemas.openxmlformats.org/officeDocument/2006/docPropsVTypes">
<Application>Microsoft Excel</Application>
<DocSecurity>0</DocSecurity>
<ScaleCrop>false</ScaleCrop>
<HeadingPairs>
<vt:vector size="2" baseType="variant">
<vt:variant>
<vt:lpstr>Worksheets</vt:lpstr>
</vt:variant>
<vt:variant>
<vt:i4>1</vt:i4>
</vt:variant>
</vt:vector>
</HeadingPairs>
<TitlesOfParts>
<vt:vector size="1" baseType="lpstr">
<vt:lpstr>Sheet1</vt:lpstr>
</vt:vector>
</TitlesOfParts>
<Company>Test Company</Company>
<LinksUpToDate>false</LinksUpToDate>
<SharedDoc>false</SharedDoc>
<HyperlinksChanged>false</HyperlinksChanged>
<AppVersion>16.0300</AppVersion>
</Properties>PK !