-
Notifications
You must be signed in to change notification settings - Fork 8
Keep one-letter skills without corrupting others #50
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -24,6 +24,142 @@ test("rejects unsupported, spoofed, empty, oversized, and invalid UTF-8 files", | |||||||||||||||||
| } | ||||||||||||||||||
| }); | ||||||||||||||||||
|
|
||||||||||||||||||
| test("single-character skills like C and R survive extraction", async () => { | ||||||||||||||||||
| // unique() used to filter tokens shorter than two characters, which was meant | ||||||||||||||||||
| // to drop stray punctuation left over from a bad split but also silently | ||||||||||||||||||
| // dropped one-letter language names -- exactly the ones a systems-programming | ||||||||||||||||||
| // resume is most likely to list. | ||||||||||||||||||
| const resume = await parseGroundingFile(txt("Skills: C, Go, Python, R, Rust"), "resume"); | ||||||||||||||||||
| assert.deepEqual(resume.skills, ["C", "Go", "Python", "R", "Rust"]); | ||||||||||||||||||
| }); | ||||||||||||||||||
|
|
||||||||||||||||||
| test("digit-led skills are not mistaken for a numbered-list marker", async () => { | ||||||||||||||||||
| // clean()'s leading-marker strip is meant for real list prefixes like "1. " | ||||||||||||||||||
| // or "2) ", not for a bare digit run: without the "then punctuation" check, | ||||||||||||||||||
| // "5G" loses its "5" and survives as the fabricated skill "G". | ||||||||||||||||||
| const resume = await parseGroundingFile(txt("Skills: C, 5G, 3D, 4K"), "resume"); | ||||||||||||||||||
| assert.deepEqual(resume.skills, ["C", "5G", "3D", "4K"]); | ||||||||||||||||||
| }); | ||||||||||||||||||
|
|
||||||||||||||||||
| test("a numbered-list marker is still stripped from a requirement line", async () => { | ||||||||||||||||||
| const jd = await parseGroundingFile(txt("1. Must know Rust\n2) Should know Go"), "jd"); | ||||||||||||||||||
| assert.deepEqual(jd.requirements, ["Must know Rust", "Should know Go"]); | ||||||||||||||||||
| }); | ||||||||||||||||||
|
|
||||||||||||||||||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
The tests still don't cover the edge case mentioned earlier. Whenever a bug occurs, it highlights a gap in our current test coverage. We should add a test for this specific scenario to prevent regressions in the future.
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I would add more tests now to edge cases. |
||||||||||||||||||
| test("a split fragment that is pure punctuation is dropped, not kept as a skill", async () => { | ||||||||||||||||||
| // A stray delimiter or copy-paste artifact landing as its own comma/semicolon | ||||||||||||||||||
| // fragment must not survive filter(Boolean) just because clean() doesn't | ||||||||||||||||||
| // happen to strip that particular symbol. | ||||||||||||||||||
| const resume = await parseGroundingFile(txt("Skills: C, /, Go, #, &, Java"), "resume"); | ||||||||||||||||||
| assert.deepEqual(resume.skills, ["C", "Go", "Java"]); | ||||||||||||||||||
| }); | ||||||||||||||||||
|
|
||||||||||||||||||
| test("lone numeric fragments are not kept as skills", async () => { | ||||||||||||||||||
| const resume = await parseGroundingFile(txt("Skills: Python, 1, 1., Rust"), "resume"); | ||||||||||||||||||
| assert.deepEqual(resume.skills, ["Python", "Rust"]); | ||||||||||||||||||
| }); | ||||||||||||||||||
|
|
||||||||||||||||||
| test("a single isolated skill with no delimiter still survives extraction", async () => { | ||||||||||||||||||
| const resume = await parseGroundingFile(txt("Skills: Python"), "resume"); | ||||||||||||||||||
| assert.deepEqual(resume.skills, ["Python"]); | ||||||||||||||||||
| }); | ||||||||||||||||||
|
|
||||||||||||||||||
| test("a skills header missing its colon is not treated as a skills line", async () => { | ||||||||||||||||||
| // parseResume only recognizes "skills/technologies/stack" followed by ":", | ||||||||||||||||||
| // so a header that drops the colon must yield no skills at all rather than | ||||||||||||||||||
| // matching loosely on the leading word. | ||||||||||||||||||
| const resume = await parseGroundingFile(txt("Skills Python, Go"), "resume"); | ||||||||||||||||||
| assert.deepEqual(resume.skills, []); | ||||||||||||||||||
| }); | ||||||||||||||||||
|
|
||||||||||||||||||
| test("header casing, synonyms, and stray whitespace around the colon are tolerated", async () => { | ||||||||||||||||||
| const resume = await parseGroundingFile(txt("TECHNOLOGIES : Python, Go"), "resume"); | ||||||||||||||||||
| assert.deepEqual(resume.skills, ["Python", "Go"]); | ||||||||||||||||||
| }); | ||||||||||||||||||
|
|
||||||||||||||||||
| test("empty segments from doubled-up delimiters are dropped, not kept as blank skills", async () => { | ||||||||||||||||||
| const resume = await parseGroundingFile(txt("Skills: Python,, Go;;Rust||C++"), "resume"); | ||||||||||||||||||
| assert.deepEqual(resume.skills, ["Python", "Go", "Rust", "C++"]); | ||||||||||||||||||
| }); | ||||||||||||||||||
|
|
||||||||||||||||||
| test("a letter or a slash or percent lets a digit-bearing token survive", async () => { | ||||||||||||||||||
| const resume = await parseGroundingFile(txt("Skills: C++11, 24/7, 100%, v2, 5, -5"), "resume"); | ||||||||||||||||||
| assert.deepEqual(resume.skills, ["C++11", "24/7", "100%", "v2"]); | ||||||||||||||||||
| }); | ||||||||||||||||||
|
|
||||||||||||||||||
| test("a bare digit.digit shape is dropped as an orphaned version or GPA fragment", async () => { | ||||||||||||||||||
| // "3.14", "5.2", and "802.11" can't be told apart from a GPA or a version | ||||||||||||||||||
| // number split off its software name -- there is no letter left to say | ||||||||||||||||||
| // which one it is, so the whole shape is dropped, standard or not. | ||||||||||||||||||
| const resume = await parseGroundingFile(txt("Skills: Python, 3.14, 5.2, 802.11, Go"), "resume"); | ||||||||||||||||||
| assert.deepEqual(resume.skills, ["Python", "Go"]); | ||||||||||||||||||
| }); | ||||||||||||||||||
|
|
||||||||||||||||||
| test("a bare multi-digit integer gets no special treatment either", async () => { | ||||||||||||||||||
| // A plain digit run has no dot to make it read as a split version number | ||||||||||||||||||
| // or GPA, but that shape isn't what decides this: parseResume only splits | ||||||||||||||||||
| // candidates on "," / ";" / "|", so "ISO 27001, 124141, 2015" always comes | ||||||||||||||||||
| // from one shared "ISO" prefix in front of several values, and once split, | ||||||||||||||||||
| // there is no way left to tell whether "124141" is still part of that | ||||||||||||||||||
| // standard, a separate one, or "2015" is a year with nothing to do with | ||||||||||||||||||
| // either. Without a letter to carry a value's scope through the split, a | ||||||||||||||||||
| // bare number carries none of its own -- so "27001" and "2015" are dropped | ||||||||||||||||||
| // exactly like "3.14" is, and only "ISO 9001" keeps its meaning. | ||||||||||||||||||
| const resume = await parseGroundingFile(txt("Skills: ISO 9001, 27001, 2015"), "resume"); | ||||||||||||||||||
| assert.deepEqual(resume.skills, ["ISO 9001"]); | ||||||||||||||||||
| }); | ||||||||||||||||||
|
|
||||||||||||||||||
| test("a generation suffix or org prefix carries a standard's number through", async () => { | ||||||||||||||||||
| // Real-world listings almost always attach a generation letter ("ac", "ax") | ||||||||||||||||||
| // or an org name ("IEEE", "Wi-Fi") to a standard's number, which is exactly | ||||||||||||||||||
| // the letter that lets it survive as its own token. | ||||||||||||||||||
| const resume = await parseGroundingFile(txt("Skills: 802.11ac, 802.11ax, IEEE 802.11, Wi-Fi 802.11"), "resume"); | ||||||||||||||||||
| assert.deepEqual(resume.skills, ["802.11ac", "802.11ax", "IEEE 802.11", "Wi-Fi 802.11"]); | ||||||||||||||||||
| }); | ||||||||||||||||||
|
|
||||||||||||||||||
| test("a standard survives named but not split off as a bare number", async () => { | ||||||||||||||||||
| // "IEEE 754" and "ISO 27001" keep their org name, so the letter carries | ||||||||||||||||||
| // them through same as any other skill. Once "754" is split off from | ||||||||||||||||||
| // "IEEE" it is just a bare digit run with no letter left to scope it, and | ||||||||||||||||||
| // is dropped the same way "802.3" is -- both are real standards, but | ||||||||||||||||||
| // neither token carries anything to say so on its own. | ||||||||||||||||||
| const resume = await parseGroundingFile(txt("Skills: IEEE 754, ISO 27001, IEEE, 754, 802.3"), "resume"); | ||||||||||||||||||
| assert.deepEqual(resume.skills, ["IEEE 754", "ISO 27001", "IEEE"]); | ||||||||||||||||||
| }); | ||||||||||||||||||
|
|
||||||||||||||||||
| test("a non-ASCII decimal digit dotted fragment is dropped like its ASCII equivalent", async () => { | ||||||||||||||||||
| // \p{N} covers any numeral script, not just ASCII 0-9, so a GPA or version | ||||||||||||||||||
| // fragment spelled in full-width or Arabic-Indic digits carries no letter | ||||||||||||||||||
| // either and is dropped the same way "3.14" is. | ||||||||||||||||||
| const resume = await parseGroundingFile(txt("Skills: Python, 3.14, ٣.١٤, Go"), "resume"); | ||||||||||||||||||
| assert.deepEqual(resume.skills, ["Python", "Go"]); | ||||||||||||||||||
| }); | ||||||||||||||||||
|
|
||||||||||||||||||
| test("digits elsewhere in a token do not earn it a letter's exemption", async () => { | ||||||||||||||||||
| // None of these carry a letter or a "/" or "%", so none of them get to | ||||||||||||||||||
| // survive as a negative number, a parenthesized GPA, a digit range, or a | ||||||||||||||||||
| // year range -- the same rule that drops a bare "27001" drops these too. | ||||||||||||||||||
| const resume = await parseGroundingFile(txt("Skills: Python, -50, (3.14), 1-2, 2020-2024, Rust"), "resume"); | ||||||||||||||||||
| assert.deepEqual(resume.skills, ["Python", "Rust"]); | ||||||||||||||||||
| }); | ||||||||||||||||||
|
|
||||||||||||||||||
| test("a shared prefix does not carry over to the values after it", async () => { | ||||||||||||||||||
| // ISO does not scope "124141" or "2015" just because it appeared earlier | ||||||||||||||||||
| // on the line -- parseResume splits Skills: ISO 27001, 124141, ISO 8981, | ||||||||||||||||||
| // 2015 into four independent candidates, and each one is judged only on | ||||||||||||||||||
| // what it itself carries. "ISO 27001" and "ISO 8981" keep their own "ISO", | ||||||||||||||||||
| // but "124141" and "2015" reached this filter with no letter of their own | ||||||||||||||||||
| // and are dropped, even though a human reader might guess they belong to | ||||||||||||||||||
| // the same certification family. | ||||||||||||||||||
| const resume = await parseGroundingFile(txt("Skills: ISO 27001, 124141, ISO 8981, 2015"), "resume"); | ||||||||||||||||||
| assert.deepEqual(resume.skills, ["ISO 27001", "ISO 8981"]); | ||||||||||||||||||
| }); | ||||||||||||||||||
|
|
||||||||||||||||||
| test("stacked list markers on one line are stripped in full, not just the first", async () => { | ||||||||||||||||||
| const jd = await parseGroundingFile(txt("1. - Must know Rust\n* 2) Should know Go"), "jd"); | ||||||||||||||||||
| assert.deepEqual(jd.requirements, ["Must know Rust", "Should know Go"]); | ||||||||||||||||||
| }); | ||||||||||||||||||
|
|
||||||||||||||||||
| test("selection requires consent and storage is one-time", () => { | ||||||||||||||||||
| const extracted = { requirements: ["Must know Rust"], skills: ["Rust"], anchors: ["Built a parser"] }; | ||||||||||||||||||
| const selected = { requirements: [0], skills: [], anchors: [0] }; | ||||||||||||||||||
|
|
||||||||||||||||||
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
|
|
@@ -115,12 +115,37 @@ function normalizeLines(text) { | |||||
| .split(/\r?\n/).map((line) => line.replace(/\s+/g, " ").trim()).filter(Boolean); | ||||||
| } | ||||||
|
|
||||||
| // Only a real list marker -- a bullet glyph, or digits immediately followed by | ||||||
| // "." or ")" -- and then whitespace counts as a prefix to strip. A bare | ||||||
| // leading digit run does not, because that is also how alphanumeric skills | ||||||
| // spell themselves: "5G" and "3D" are not "5" and "3" with a stray marker in | ||||||
| // front. | ||||||
| function clean(line) { | ||||||
| return line.replace(/^[-*•\d.)\s]+/, "").slice(0, textLimit).trim(); | ||||||
| return line.trimStart().replace(/^(?:(?:[-*]|\d+[.)])\s+|•\s*)+/, "").slice(0, textLimit).trim(); | ||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Requiring whitespace after the marker breaks list styles that
Suggested change
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I would check this later. |
||||||
| } | ||||||
|
cubic-dev-ai[bot] marked this conversation as resolved.
|
||||||
|
|
||||||
| // A token with no letter is a number, or nothing but punctuation, wearing a | ||||||
| // list item's clothes, and neither one is a skill on its own. | ||||||
| // | ||||||
| // A letter is what makes a token legible as a named thing: "ISO 27001" and | ||||||
| // "IEEE 754" keep the org name that scopes their number, "5G" and "3D" carry | ||||||
| // their own label, and a plain "5" or "27001" or "2015" split off from any | ||||||
| // of those carries no such scope. parseResume only splits candidates on "," | ||||||
| // / ";" / "|", so a bare number always comes from the source listing several | ||||||
| // values after one shared prefix -- "Skills: ISO 27001, 124141, 2015" -- and | ||||||
| // once split, there is no way left to tell whether "124141" or "2015" is | ||||||
| // still part of that standard, a separate one, or an unrelated year. Rather | ||||||
| // than guess, every letterless token is dropped except the one shape that | ||||||
| // has no such ambiguity: a digit paired with "/" or "%" ("24/7", "100%"), | ||||||
| // which reads as a ratio or a percentage and nothing else. That also drops | ||||||
| // "3.14", "802.11", "-50", "(3.14)", "1-2", and "2020-2024" -- none of them | ||||||
| // carry a letter, so none of them get to claim a meaning others would have | ||||||
| // to guess at. | ||||||
| function unique(values, max) { | ||||||
| return [...new Set(values.map(clean).filter((value) => value.length >= 2))].slice(0, max); | ||||||
| return [...new Set(values.map(clean).filter((value) => { | ||||||
| if (/\p{L}/u.test(value)) return true; | ||||||
| return /\p{N}/u.test(value) && /[/%]/.test(value); | ||||||
| }))].slice(0, max); | ||||||
| } | ||||||
|
|
||||||
| function parseJd(lines) { | ||||||
|
|
||||||
Uh oh!
There was an error while loading. Please reload this page.